From 6406605191fe26fe901d7b17899be47fc9e9dcca Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 3 Jun 2026 00:27:51 +0000 Subject: [PATCH 01/39] checkpoint(ia360): snapshot motor+freetext-agent+booking antes de fix arranque en frio Snapshot de la implementacion viva (warm path funcional end-to-end: deal id=5 con Zoom+Calendar reales) como red de seguridad antes de desplegar el fix de arranque en frio (alias texto->ID de plantilla). Solo archivos fuente; secretos (.env.bak, .forgechat-credentials) excluidos. --- backend/src/routes/templates.js | 25 +- backend/src/routes/webhook.js | 1345 ++++++++++++++++++++ backend/src/services/ia360Mapping.js | 76 ++ backend/test/ia360EspoMapping.test.js | 33 + backend/test/ia360FunnelSimulation.test.js | 69 + backend/test/ia360Mapping.test.js | 19 + frontend/src/pages/PipelinesPage.jsx | 13 +- 7 files changed, 1573 insertions(+), 7 deletions(-) create mode 100644 backend/src/services/ia360Mapping.js create mode 100644 backend/test/ia360EspoMapping.test.js create mode 100644 backend/test/ia360FunnelSimulation.test.js create mode 100644 backend/test/ia360Mapping.test.js diff --git a/backend/src/routes/templates.js b/backend/src/routes/templates.js index 9213823..80c8d1e 100644 --- a/backend/src/routes/templates.js +++ b/backend/src/routes/templates.js @@ -787,7 +787,7 @@ router.get('/templates/:id/payload', async (req, res) => { /** * POST /templates/:id/test-send - * Body: { to: '919xxx', sampleValues?: { '1': 'John', '2': 'ORD-123' } } + * Body: { to: '919xxx', sampleValues?: { '1': 'John', '2': 'ORD-123' }, headerImageMediaId?: '...', headerImageLink?: 'https://...' } * Sends the template via the WhatsApp account linked to this template. */ const { resolveAccount, insertPendingRow } = require('../services/messageSender'); @@ -795,11 +795,11 @@ const { enqueueSend } = require('../queue/sendQueue'); router.post('/templates/:id/test-send', requirePermission('template-builder'), async (req, res) => { try { - const { to, sampleValues = {} } = req.body || {}; + const { to, sampleValues = {}, headerImageMediaId = '', headerImageLink = '' } = req.body || {}; if (!to) return res.status(400).json({ error: 'to (recipient phone) required' }); const { rows } = await pool.query( - `SELECT id, name, language, body, whatsapp_account_id FROM coexistence.message_templates WHERE id = $1`, + `SELECT id, name, language, body, header_type, whatsapp_account_id FROM coexistence.message_templates WHERE id = $1`, [req.params.id] ); if (rows.length === 0) return res.status(404).json({ error: 'Template not found' }); @@ -811,9 +811,22 @@ router.post('/templates/:id/test-send', requirePermission('template-builder'), a // Build components from sampleValues (sorted numerically by var index) const keys = Object.keys(sampleValues).sort((a, b) => +a - +b); - const components = keys.length > 0 - ? [{ type: 'body', parameters: keys.map(k => ({ type: 'text', text: String(sampleValues[k] || ' ') })) }] - : []; + const components = []; + if (tpl.header_type === 'IMAGE') { + if (!headerImageMediaId && !headerImageLink) { + return res.status(400).json({ error: 'Template has IMAGE header; provide headerImageMediaId or headerImageLink' }); + } + components.push({ + type: 'header', + parameters: [{ + type: 'image', + image: headerImageMediaId ? { id: String(headerImageMediaId) } : { link: String(headerImageLink) }, + }], + }); + } + if (keys.length > 0) { + components.push({ type: 'body', parameters: keys.map(k => ({ type: 'text', text: String(sampleValues[k] || ' ') })) }); + } const localId = await insertPendingRow({ account, toNumber: to, messageType: 'template', messageBody: tpl.body || `Template: ${tpl.name}`, diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index c65ca9f..3f38487 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -5,6 +5,9 @@ const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); const router = Router(); @@ -112,6 +115,9 @@ function parseMetaPayload(body) { const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; record.message_body = reply.title || 'Interactive response'; record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; } else if (type === 'reaction' && msg.reaction) { record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; record.message_type = 'reaction'; @@ -180,6 +186,1342 @@ function parseMetaPayload(body) { return records; } +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if (agent.action === 'offer_slots' && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId] || answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + /** * POST /api/webhook/whatsapp * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. @@ -328,6 +1670,9 @@ router.post('/webhook/whatsapp', async (req, res) => { } continue; // do not also fire fresh triggers } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); await evaluateTriggers(record); } catch (triggerErr) { console.error('[webhook] Trigger evaluation error:', triggerErr.message); diff --git a/backend/src/services/ia360Mapping.js b/backend/src/services/ia360Mapping.js new file mode 100644 index 0000000..d4f7397 --- /dev/null +++ b/backend/src/services/ia360Mapping.js @@ -0,0 +1,76 @@ +function normalizeText(value) { + return String(value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); +} + +const EVENT_STAGE = { + meeting_confirmed_calendar_zoom: 'Reunión agendada', + agenda_preference_selected: 'Agenda en proceso', + call_requested: 'Agenda en proceso', + proposal_requested: 'Propuesta / siguiente paso', + apply_requested: 'Requiere Alek', + scope_requested: 'Requiere Alek', + map_requested: 'Diagnóstico enviado', + flow_map_requested: 'Dolor calificado', + example_requested: 'Dolor calificado', + mechanism_selected: 'Dolor calificado', + pain_segmented: 'Dolor calificado', + nurture_selected: 'Nutrición', + opt_out: 'Perdido / no fit', + negative_feedback: 'Requiere Alek', +}; + +const REPLY_STAGE_BY_ID = { + wa_flow_map: 'Dolor calificado', + flow_architecture: 'Dolor calificado', + next_example: 'Dolor calificado', + '100m_see_example': 'Dolor calificado', + ex_wa_crm: 'Dolor calificado', + '100m_wa_crm': 'Dolor calificado', + ex_erp_bi: 'Dolor calificado', + '100m_erp_bi': 'Dolor calificado', + ex_agent_followup: 'Dolor calificado', + '100m_want_map': 'Diagnóstico enviado', + next_5q: 'Diagnóstico enviado', + wa_apply: 'Requiere Alek', + apply_scope: 'Requiere Alek', + apply_cost: 'Propuesta / siguiente paso', + wa_schedule: 'Agenda en proceso', + '100m_schedule': 'Agenda en proceso', + next_schedule: 'Agenda en proceso', + apply_call: 'Agenda en proceso', +}; + +function getIa360StageForEvent(eventType, fallback = null) { + return EVENT_STAGE[eventType] || fallback; +} + +function getIa360StageForReply({ replyId, answer } = {}, fallback = null) { + if (replyId && REPLY_STAGE_BY_ID[replyId]) return REPLY_STAGE_BY_ID[replyId]; + const text = normalizeText(answer); + if (!text) return fallback; + if ( + text.includes('pendej') || + text.includes('basta de pruebas') || + text.includes('no me sirve') || + text.includes('pruebas sueltas') || + text.includes('pruebas a lo') || + text.includes('molesto') || + text.includes('esto esta mal') || + text.includes('no sigas') + ) return 'Requiere Alek'; + if (text.includes('ver flujo') || text.includes('arquitectura') || text.includes('ver ejemplo') || text.includes('whatsapp') || text.includes('erp') || text.includes('agente')) return 'Dolor calificado'; + if (text.includes('quiero mapa') || text.includes('5 pregunta')) return 'Diagnóstico enviado'; + if (text.includes('aplicarlo') || text.includes('alcance')) return 'Requiere Alek'; + if (text.includes('costo') || text.includes('propuesta')) return 'Propuesta / siguiente paso'; + if (text.includes('agendar') || text.includes('llamada')) return 'Agenda en proceso'; + return fallback; +} + +module.exports = { + getIa360StageForEvent, + getIa360StageForReply, +}; diff --git a/backend/test/ia360EspoMapping.test.js b/backend/test/ia360EspoMapping.test.js new file mode 100644 index 0000000..e5f76a2 --- /dev/null +++ b/backend/test/ia360EspoMapping.test.js @@ -0,0 +1,33 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +function cleanDigits(v) { return String(v || '').replace(/\D/g, ''); } +function mapEspoStage(eventType, targetStage) { + if (eventType === 'proposal_requested' || targetStage === 'Propuesta / siguiente paso') return 'Proposal'; + if (eventType === 'opt_out') return 'Closed Lost'; + if (eventType === 'meeting_confirmed_calendar_zoom') return 'Qualification'; + if (eventType === 'agenda_preference_selected' || eventType === 'call_requested') return 'Qualification'; + if (eventType === 'nurture_selected') return null; + return targetStage ? 'Qualification' : 'Prospecting'; +} +function shouldCreateTask(eventType, priority) { + return priority === 'high' || [ + 'apply_requested', 'scope_requested', 'proposal_requested', 'call_requested', + 'agenda_preference_selected', 'meeting_confirmed_calendar_zoom', + 'diagnostic_answered' + ].includes(eventType); +} + +test('Espo stage mapping keeps meeting confirmed in commercial qualification until custom stage exists', () => { + assert.equal(mapEspoStage('meeting_confirmed_calendar_zoom', 'Reunión agendada'), 'Qualification'); + assert.equal(mapEspoStage('proposal_requested', 'Propuesta / siguiente paso'), 'Proposal'); + assert.equal(mapEspoStage('opt_out', 'Perdido / no fit'), 'Closed Lost'); + assert.equal(mapEspoStage('nurture_selected', 'Nutrición'), null); +}); + +test('task creation only for high-intent or high-priority events', () => { + assert.equal(shouldCreateTask('nurture_selected', 'normal'), false); + assert.equal(shouldCreateTask('mechanism_selected', 'normal'), false); + assert.equal(shouldCreateTask('call_requested', 'normal'), true); + assert.equal(shouldCreateTask('mechanism_selected', 'high'), true); +}); diff --git a/backend/test/ia360FunnelSimulation.test.js b/backend/test/ia360FunnelSimulation.test.js new file mode 100644 index 0000000..af346bd --- /dev/null +++ b/backend/test/ia360FunnelSimulation.test.js @@ -0,0 +1,69 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../src/services/ia360Mapping'); + +function espoStage(eventType, targetStage) { + if (eventType === 'proposal_requested' || targetStage === 'Propuesta / siguiente paso') return 'Proposal'; + if (eventType === 'opt_out') return 'Closed Lost'; + if (eventType === 'nurture_selected') return null; + if (eventType === 'meeting_confirmed_calendar_zoom') return 'Qualification'; + if (eventType === 'agenda_preference_selected' || eventType === 'call_requested') return 'Qualification'; + if (eventType === 'negative_feedback') return 'Qualification'; + return targetStage ? 'Qualification' : 'Prospecting'; +} + +function shouldCreateTask(eventType, priority = 'normal') { + return priority === 'high' || [ + 'apply_requested', 'scope_requested', 'proposal_requested', 'call_requested', + 'agenda_preference_selected', 'meeting_confirmed_calendar_zoom', + 'diagnostic_answered', 'negative_feedback' + ].includes(eventType); +} + +const scenarios = [ + { + name: 'Ruta WhatsApp Revenue OS alta intención hasta reunión', + steps: [ + ['reply', { replyId: '100m_wa_crm', answer: 'WhatsApp → CRM' }, 'Dolor calificado', 'mechanism_selected', 'Qualification', false], + ['reply', { replyId: 'wa_flow_map', answer: 'Ver flujo' }, 'Dolor calificado', 'flow_map_requested', 'Qualification', false], + ['reply', { replyId: 'wa_apply', answer: 'Aplicarlo' }, 'Requiere Alek', 'apply_requested', 'Qualification', true], + ['reply', { replyId: 'apply_call', answer: 'Llamada' }, 'Agenda en proceso', 'call_requested', 'Qualification', true], + ['event', 'meeting_confirmed_calendar_zoom', 'Reunión agendada', 'meeting_confirmed_calendar_zoom', 'Qualification', true], + ] + }, + { + name: 'Ruta propuesta directa por costo', + steps: [ + ['reply', { replyId: 'apply_cost', answer: 'Costo' }, 'Propuesta / siguiente paso', 'proposal_requested', 'Proposal', true], + ] + }, + { + name: 'Ruta nutrición no crea oportunidad ni tarea', + steps: [ + ['event', 'nurture_selected', 'Nutrición', 'nurture_selected', null, false], + ] + }, + { + name: 'Ruta baja cierra/no contactar', + steps: [ + ['event', 'opt_out', 'Perdido / no fit', 'opt_out', 'Closed Lost', false], + ] + }, + { + name: 'Feedback negativo requiere Alek y no debe continuar automático', + steps: [ + ['reply', { answer: 'De que me sirve que me mandes pruebas a lo pendejo. Basta de pruebas pendejas.' }, 'Requiere Alek', 'negative_feedback', 'Qualification', true], + ] + } +]; + +test('IA360 dry-run funnel scenarios route to expected stages/tasks', () => { + for (const scenario of scenarios) { + for (const [kind, input, expectedStage, eventType, expectedEspo, expectedTask] of scenario.steps) { + const stage = kind === 'event' ? getIa360StageForEvent(input) : getIa360StageForReply(input); + assert.equal(stage, expectedStage, `${scenario.name}: ForgeChat stage`); + assert.equal(espoStage(eventType, stage), expectedEspo, `${scenario.name}: Espo stage`); + assert.equal(shouldCreateTask(eventType), expectedTask, `${scenario.name}: task flag`); + } + } +}); diff --git a/backend/test/ia360Mapping.test.js b/backend/test/ia360Mapping.test.js new file mode 100644 index 0000000..9da81ad --- /dev/null +++ b/backend/test/ia360Mapping.test.js @@ -0,0 +1,19 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { getIa360StageForEvent, getIa360StageForReply } = require('../src/services/ia360Mapping'); + +test('meeting confirmation maps to Reunión agendada, while preferences stay Agenda en proceso', () => { + assert.equal(getIa360StageForEvent('meeting_confirmed_calendar_zoom'), 'Reunión agendada'); + assert.equal(getIa360StageForEvent('agenda_preference_selected'), 'Agenda en proceso'); + assert.equal(getIa360StageForEvent('call_requested'), 'Agenda en proceso'); +}); + +test('informational clicks do not jump to proposal, but apply/cost do', () => { + assert.equal(getIa360StageForReply({ replyId: 'wa_flow_map', answer: 'ver flujo' }), 'Dolor calificado'); + assert.equal(getIa360StageForReply({ replyId: 'ex_wa_crm', answer: 'WhatsApp → CRM' }), 'Dolor calificado'); + assert.equal(getIa360StageForReply({ replyId: 'wa_apply', answer: 'aplicarlo' }), 'Requiere Alek'); + assert.equal(getIa360StageForReply({ replyId: 'apply_scope', answer: 'alcance' }), 'Requiere Alek'); + assert.equal(getIa360StageForReply({ replyId: 'apply_cost', answer: 'costo' }), 'Propuesta / siguiente paso'); + assert.equal(getIa360StageForReply({ replyId: 'apply_call', answer: 'llamada' }), 'Agenda en proceso'); +}); diff --git a/frontend/src/pages/PipelinesPage.jsx b/frontend/src/pages/PipelinesPage.jsx index f52c564..a0512f0 100644 --- a/frontend/src/pages/PipelinesPage.jsx +++ b/frontend/src/pages/PipelinesPage.jsx @@ -22,6 +22,7 @@ export default function PipelinesPage({ user }) { const [deals, setDeals] = useState([]); const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); const [dealsLoading, setDealsLoading] = useState(false); const [pipeMenuOpen, setPipeMenuOpen] = useState(false); @@ -38,6 +39,7 @@ export default function PipelinesPage({ user }) { const loadPipelines = useCallback(async (keepId) => { setLoading(true); + setLoadError(''); try { const data = await api.pipelines.list(); setPipelines(data); @@ -49,6 +51,9 @@ export default function PipelinesPage({ user }) { }); } catch (err) { console.error(err); + setPipelines([]); + setSelectedId(null); + setLoadError(err?.message || 'No se pudieron cargar los pipelines'); } finally { setLoading(false); } @@ -169,7 +174,13 @@ export default function PipelinesPage({ user }) { {/* Kanban */} - {!selected ? ( + {loadError ? ( +
+
No se pudieron cargar los pipelines.
+
{loadError}
+ +
+ ) : !selected ? ( ) : (
From 0f34a0f9cc970288411e46484339f15bd3f4ea83 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 3 Jun 2026 01:06:57 +0000 Subject: [PATCH 02/39] fix(ia360): arranque en frio robusto + G-G hot-lead handoff + action=book + areaMap - webhook.js:865 lookup cold-start independiente de message_body (reply100m[replyId]) - G-G: hot lead que llega a Requiere Alek emite handoff a EspoCRM (visible aunque no agende) - handleIa360FreeText maneja action=book como offer_slots (no dead-end con fecha+hora exacta) - areaMap: erp/bi y agentes ia (botones plantilla-7 sin handler) Verificado end-to-end: tap firmado type:button Diagnostico rapido -> deal avanza a Intencion detectada (stage 8) + responde micro-paso. Regresion: deal id=5 intacto. Deployed == container (StartedAt 01:04:32Z > backup). --- backend/src/routes/webhook.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 3f38487..b32e78b 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -641,7 +641,7 @@ async function handleIa360FreeText(record) { } // OFFER SLOTS → query REAL availability for the agent's date, send list. - if (agent.action === 'offer_slots' && agent.date) { + if ((agent.action === 'offer_slots' || agent.action === 'book') && agent.date) { await syncIa360Deal({ record, targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), @@ -862,7 +862,7 @@ async function handleIa360LiteInteractive(record) { '100m_apply_later': 'aplicarlo', '100m_optout': 'baja', }; - const flow100m = reply100m[key100mById[replyId] || answer]; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; if (flow100m) { await mergeContactIa360State({ waNumber: record.wa_number, @@ -881,6 +881,18 @@ async function handleIa360LiteInteractive(record) { titleSuffix: flow100m.title, notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, }); + // G-G: hot lead que alcanza "Requiere Alek" debe surgir en EspoCRM aunque + // nunca se autoagende (si no, el lead mas caliente es invisible para Alek). + // Idempotente: el handoff n8n hace upsert de Contact/Opportunity/Task por nombre. + if (flow100m.stage === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'hot_lead_requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Hot lead IA360 (${flow100m.title}): "${record.message_body}". Crear/actualizar Contact + Opportunity (Qualification) + Task ALTA; preparar contexto para que Alek contacte de inmediato.`, + }).catch(e => console.error('[ia360-n8n] hot-lead handoff:', e.message)); + } if (flow100m.buttons.length > 0) { await enqueueIa360Interactive({ record, @@ -968,7 +980,9 @@ async function handleIa360LiteInteractive(record) { 'operacion': { tag: 'interes-synapse', area: 'Operación' }, 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, }; const mapped = areaMap[replyId] || areaMap[answer]; if (mapped) { From cfde731ccef6e6b03e50b557dcb3292964ba2277 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 3 Jun 2026 01:21:02 +0000 Subject: [PATCH 03/39] revert(ia360): quitar handoff G-G duplicado en flow100m (syncIa360Deal ya emite requires_alek) syncIa360Deal ya emite el handoff requires_alek (priority high) al entrar a Requiere Alek (ramas create+move, transicion idempotente). El bloque agregado en flow100m era redundante y causaba doble handoff. FIX#1/action=book/areaMap intactos. --- backend/src/routes/webhook.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index b32e78b..1e1dc69 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -881,18 +881,6 @@ async function handleIa360LiteInteractive(record) { titleSuffix: flow100m.title, notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, }); - // G-G: hot lead que alcanza "Requiere Alek" debe surgir en EspoCRM aunque - // nunca se autoagende (si no, el lead mas caliente es invisible para Alek). - // Idempotente: el handoff n8n hace upsert de Contact/Opportunity/Task por nombre. - if (flow100m.stage === 'Requiere Alek') { - emitIa360N8nHandoff({ - record, - eventType: 'hot_lead_requires_alek', - targetStage: 'Requiere Alek', - priority: 'high', - summary: `Hot lead IA360 (${flow100m.title}): "${record.message_body}". Crear/actualizar Contact + Opportunity (Qualification) + Task ALTA; preparar contexto para que Alek contacte de inmediato.`, - }).catch(e => console.error('[ia360-n8n] hot-lead handoff:', e.message)); - } if (flow100m.buttons.length > 0) { await enqueueIa360Interactive({ record, From 42fcdd5f3a23022e684c43321b8384dce429627a Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Thu, 4 Jun 2026 01:10:46 +0000 Subject: [PATCH 04/39] fix(ia360): wantsList gate (list_bookings alcanzable en Reunion agendada) + nits copy cancelacion + logs verbosos wantsList: mensajes tipo cuales tengo (sin palabras cita/reunion) eran inalcanzables con el deal en Reunion agendada (el gate solo dejaba pasar agendar/reagendar/cancelar), asi list_bookings nunca corria. Se agrego clausula wantsList. Nits: plural Le queda 1 reunion vs Le quedan N reuniones; quita doble punto tras a.m. en el aviso de cancelacion al contacto. Logs verbosos: ia360-agent (clasificacion), ia360-list, ia360-multicita append/remove. Validado en prueba viva 2026-06-03: 12 caminos PASS, borrado GCal verificado en n8n. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/webhook.js | 1311 +++++++++++++++++++++++++++++++-- 1 file changed, 1248 insertions(+), 63 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 1e1dc69..0e22539 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -357,6 +357,36 @@ async function enqueueIa360Interactive({ record, label, messageBody, interactive return true; } +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + async function enqueueIa360Text({ record, label, body }) { const resolved = await resolveIa360Outbound(record); if (resolved.duplicate || resolved.error) return false; @@ -547,10 +577,19 @@ async function getActiveNonTerminalIa360Deal(record) { // Reschedule intent: a prospect with an already-booked meeting who wants to move it // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); // Always-terminal = won/lost → never auto-answer. if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; - // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). - if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; return deal; } @@ -585,16 +624,395 @@ async function callIa360Agent({ record, stageName }) { } } +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; try { if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; if (!record.message_body || !String(record.message_body).trim()) return; const deal = await getActiveNonTerminalIa360Deal(record); if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; const agent = await callIa360Agent({ record, stageName: deal.stage_name }); if (!agent || !agent.reply) { // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; return; } @@ -604,22 +1022,123 @@ async function handleIa360FreeText(record) { // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. if (deal.stage_name === 'Reunión agendada') { - const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); - await enqueueIa360Text({ - record, - label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', - body: isCancel - ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' - : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', - }); - emitIa360N8nHandoff({ - record, - eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', - targetStage: 'Reunión agendada', - priority: 'high', - summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, - }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); - return; + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } } await mergeContactIa360State({ @@ -637,67 +1156,538 @@ async function handleIa360FreeText(record) { if (agent.action === 'optout' || agent.intent === 'optout') { await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; return; } - // OFFER SLOTS → query REAL availability for the agent's date, send list. - if ((agent.action === 'offer_slots' || agent.action === 'book') && agent.date) { - await syncIa360Deal({ + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ record, - targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), - titleSuffix: 'Agenda (texto libre)', - notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, }); - // Single outbound only (resolveIa360Outbound dedups per inbound message_id): - // fold the agent reply into the slot-list body below instead of a separate text. - // Availability node accepts an explicit ISO `date` override (NOT `day`). - const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; - let availability = null; - if (url) { - try { - const r = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), - }); - availability = r.ok ? await r.json() : null; - } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); } - const slots = (availability && availability.slots) || []; - if (slots.length === 0) { - await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); - return; + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); await enqueueIa360Interactive({ record, - label: 'ia360_ai_available_slots', - messageBody: `IA360: horarios disponibles ${availability.date}`, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, interactive: { - type: 'list', - header: { type: 'text', text: 'Horarios libres' }, - body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, - footer: { text: 'Se revalida antes de reservar' }, - action: { - button: 'Elegir hora', - sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], - }, + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, }, }); - return; + return true; } - // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). - if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { - await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; } - await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; } catch (err) { - console.error('[ia360-agent] handleIa360FreeText error:', err.message); + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; } } async function handleIa360LiteInteractive(record) { if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; const answer = String(record.message_body || '').trim().toLowerCase(); const replyId = getInteractiveReplyId(record); @@ -705,6 +1695,7 @@ async function handleIa360LiteInteractive(record) { const reply100m = { 'diagnóstico rápido': { stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', buttons: [ { id: '100m_capture_manual', title: 'Captura manual' }, @@ -714,6 +1705,7 @@ async function handleIa360LiteInteractive(record) { }, 'ver mapa 30-60-90': { stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', buttons: [ { id: '100m_capture_manual', title: 'Captura manual' }, @@ -732,6 +1724,7 @@ async function handleIa360LiteInteractive(record) { }, 'captura manual': { stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', buttons: [ { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, @@ -741,6 +1734,7 @@ async function handleIa360LiteInteractive(record) { }, 'reportes tarde': { stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', buttons: [ { id: '100m_erp_bi', title: 'ERP → BI' }, @@ -750,6 +1744,7 @@ async function handleIa360LiteInteractive(record) { }, 'seguimiento ventas': { stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', buttons: [ { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, @@ -759,6 +1754,7 @@ async function handleIa360LiteInteractive(record) { }, 'whatsapp → crm': { stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', buttons: [ { id: '100m_want_map', title: 'Quiero mapa' }, @@ -768,6 +1764,7 @@ async function handleIa360LiteInteractive(record) { }, 'erp → bi': { stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', buttons: [ { id: '100m_want_map', title: 'Quiero mapa' }, @@ -777,6 +1774,7 @@ async function handleIa360LiteInteractive(record) { }, 'agente follow-up': { stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', buttons: [ { id: '100m_want_map', title: 'Quiero mapa' }, @@ -786,6 +1784,7 @@ async function handleIa360LiteInteractive(record) { }, 'quiero mapa': { stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', buttons: [ { id: '100m_urgent', title: 'Sí, urgente' }, @@ -795,6 +1794,7 @@ async function handleIa360LiteInteractive(record) { }, 'ver ejemplo': { stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', buttons: [ { id: '100m_want_map', title: 'Quiero mapa' }, @@ -804,6 +1804,7 @@ async function handleIa360LiteInteractive(record) { }, 'sí, urgente': { stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', buttons: [ { id: 'sched_today', title: 'Hoy' }, @@ -881,6 +1882,26 @@ async function handleIa360LiteInteractive(record) { titleSuffix: flow100m.title, notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } if (flow100m.buttons.length > 0) { await enqueueIa360Interactive({ record, @@ -888,7 +1909,7 @@ async function handleIa360LiteInteractive(record) { messageBody: `IA360 100M: ${flow100m.title}`, interactive: { type: 'button', - header: { type: 'text', text: flow100m.title }, + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, body: { text: flow100m.body }, footer: { text: 'IA360 · micro-paso' }, action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, @@ -901,7 +1922,7 @@ async function handleIa360LiteInteractive(record) { messageBody: `IA360 100M: ${flow100m.title}`, interactive: { type: 'button', - header: { type: 'text', text: flow100m.title }, + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, body: { text: flow100m.body }, footer: { text: 'IA360' }, action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, @@ -1274,9 +2295,114 @@ async function handleIa360LiteInteractive(record) { return; } - const selectedSlot = parseIa360SlotId(replyId); - if (selectedSlot) { - const booking = await bookIa360Slot({ record, ...selectedSlot }); + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); if (!booking?.ok) { await enqueueIa360Text({ record, @@ -1293,8 +2419,25 @@ async function handleIa360LiteInteractive(record) { ia360_ultima_respuesta: record.message_body, proximo_followup: `Reunión confirmada: ${booking.start}`, ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', }, }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } await syncIa360Deal({ record, targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), @@ -1655,6 +2798,48 @@ router.post('/webhook/whatsapp', async (req, res) => { if (incomingRecords.length > 0) { for (const record of incomingRecords) { try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + const { rows: pausedRows } = await pool.query( `SELECT id FROM coexistence.automation_executions WHERE wa_number=$1 AND contact_number=$2 From 7312b384ba84a39b881b335a6baa2c94e7fad377 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Fri, 5 Jun 2026 23:09:38 +0000 Subject: [PATCH 05/39] Implement persona-first vCard sequence selector --- backend/src/routes/webhook.js | 1587 +++++++++++++++++++++++++++++++-- 1 file changed, 1530 insertions(+), 57 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 0e22539..4545bd4 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -204,6 +204,81 @@ async function mergeContactIa360State({ waNumber, contactNumber, tags = [], cust ); } +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; const { rows: pipeRows } = await pool.query( @@ -311,14 +386,17 @@ async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes return { id: existing.id, moved: shouldMove }; } -async function resolveIa360Outbound(record) { +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; const { rows } = await pool.query( `SELECT 1 FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number=$1 AND template_meta->>'ia360_handler_for'=$2 LIMIT 1`, - [record.contact_number, record.message_id] + [record.contact_number, handlerFor] ); if (rows.length > 0) return { duplicate: true }; @@ -330,8 +408,8 @@ async function resolveIa360Outbound(record) { return { account }; } -async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { - const resolved = await resolveIa360Outbound(record); +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); if (resolved.duplicate || resolved.error) return false; const { account } = resolved; @@ -343,7 +421,7 @@ async function enqueueIa360Interactive({ record, label, messageBody, interactive templateMeta: { ux: 'ia360_lite', label, - ia360_handler_for: record.message_id, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, source: 'webhook_interactive_reply', }, }); @@ -387,6 +465,38 @@ async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, }); } +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + async function enqueueIa360Text({ record, label, body }) { const resolved = await resolveIa360Outbound(record); if (resolved.duplicate || resolved.error) return false; @@ -414,6 +524,166 @@ async function enqueueIa360Text({ record, label, body }) { return true; } +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; if (!url) return false; @@ -593,9 +863,44 @@ async function getActiveNonTerminalIa360Deal(record) { return deal; } +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + async function callIa360Agent({ record, stageName }) { - const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; - if (!url) return null; // Recent conversation for context (last 8 messages). const { rows: hist } = await pool.query( `SELECT direction AS dir, message_body AS body @@ -605,16 +910,24 @@ async function callIa360Agent({ record, stageName }) { [record.wa_number, record.contact_number] ); const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; try { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - source: 'forgechat-ia360-webhook', - text: record.message_body || '', - stage: stageName, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, history, - }), + source: 'forgechat-ia360-webhook', + })), }); if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } return await res.json(); @@ -627,6 +940,952 @@ async function callIa360Agent({ record, stageName }) { // ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── // Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la // rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const copyStatus = hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: true, + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: 'requires_alek', + approved_by: '', + approved_at: '', + reason: 'Requiere aprobación humana antes de cualquier envío externo.', + }, + guardrail: { + current_block: 'requires_human_approval', + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + 'Bloqueo actual: requiere aprobación humana; no hay envío externo al contacto.', + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.` }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.` }); + return; + } + const { flow, sequence } = found; + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + const IA360_OWNER_NUMBER = '5213322638033'; // Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la @@ -999,6 +2258,8 @@ async function handleIa360FreeText(record) { // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { const bookings = await loadIa360Bookings(record.contact_number); console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); @@ -1322,6 +2583,19 @@ const URGENCIA_LEGIBLE = { explorando: 'exploración', }; +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + async function handleIa360FlowReply(record) { try { if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; @@ -1332,6 +2606,7 @@ async function handleIa360FlowReply(record) { if (data.area !== undefined && data.urgencia !== undefined) { // ── DIAGNOSTICO ─────────────────────────────────────────────────────── const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); await mergeContactIa360State({ waNumber: record.wa_number, contactNumber: record.contact_number, @@ -1418,6 +2693,7 @@ async function handleIa360FlowReply(record) { if (data.tamano_empresa !== undefined) { // ── OFFER_ROUTER ────────────────────────────────────────────────────── const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; await mergeContactIa360State({ @@ -1432,6 +2708,9 @@ async function handleIa360FlowReply(record) { titleSuffix: 'Oferta ' + tier + ' (Flow)', notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; await enqueueIa360Interactive({ record, label: 'ia360_flow_offer_router', @@ -1439,11 +2718,10 @@ async function handleIa360FlowReply(record) { interactive: { type: 'button', header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, - body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, footer: { text: 'IA360' }, action: { buttons: [ - { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, - { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, ] }, }, }); @@ -1453,6 +2731,7 @@ async function handleIa360FlowReply(record) { if (data.empresa !== undefined && data.rol !== undefined) { // ── PRE_CALL ────────────────────────────────────────────────────────── const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); await mergeContactIa360State({ waNumber: record.wa_number, contactNumber: record.contact_number, @@ -1464,27 +2743,46 @@ async function handleIa360FlowReply(record) { ia360_sistemas: sistemas, }, }); - await syncIa360Deal({ - record, - targetStageName: 'Agenda en proceso', - titleSuffix: 'Pre-call (Flow)', - notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, - }); - await enqueueIa360Interactive({ - record, - label: 'ia360_flow_pre_call', - messageBody: 'IA360 Flow: pre-call intake', - interactive: { - type: 'button', - header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, - body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, - footer: { text: 'IA360' }, - action: { buttons: [ - { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, - { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, - ] }, - }, - }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } return true; } @@ -1498,11 +2796,12 @@ async function handleIa360FlowReply(record) { tags: ['no-contactar'], customFields: { ia360_preferencia: preferencia }, }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (Flow)', - notes: 'preferences: no_contactar', + notes: 'opt_out: no_contactar', }); await enqueueIa360Text({ record, @@ -1516,25 +2815,19 @@ async function handleIa360FlowReply(record) { tags: ['preferencia:' + preferencia], customFields: { ia360_preferencia: preferencia }, }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); await syncIa360Deal({ record, targetStageName: 'Nutrición', titleSuffix: 'Preferencias (Flow)', - notes: `preferences: ${preferencia}`, + notes: `nurture_selected: ${preferencia}`, }); - await enqueueIa360Interactive({ + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ record, label: 'ia360_flow_preferences', - messageBody: 'IA360 Flow: preferencias', - interactive: { - type: 'button', - header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, - body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, - footer: { text: 'IA360' }, - action: { buttons: [ - { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, - ] }, - }, + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', }); } return true; @@ -1557,10 +2850,22 @@ async function handleIa360LiteInteractive(record) { const ownerReplyId = getInteractiveReplyId(record); if (ownerReplyId && ownerReplyId.startsWith('owner_')) { try { - const [ownerAction, ownerArg] = ownerReplyId.split(':'); + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } if (ownerAction === 'owner_cancel_yes') { // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id @@ -1622,7 +2927,7 @@ async function handleIa360LiteInteractive(record) { if (ownerAction === 'owner_take_fail') { const fid = String(ownerArg || '').replace(/\D/g, ''); if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); - await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); return; } if (ownerAction === 'owner_ignore_fail') { @@ -1691,6 +2996,18 @@ async function handleIa360LiteInteractive(record) { const answer = String(record.message_body || '').trim().toLowerCase(); const replyId = getInteractiveReplyId(record); + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. const reply100m = { 'diagnóstico rápido': { @@ -1902,6 +3219,44 @@ async function handleIa360LiteInteractive(record) { console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); } } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } if (flow100m.buttons.length > 0) { await enqueueIa360Interactive({ record, @@ -2122,7 +3477,7 @@ async function handleIa360LiteInteractive(record) { return; } - if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { await mergeContactIa360State({ waNumber: record.wa_number, contactNumber: record.contact_number, @@ -2454,6 +3809,27 @@ async function handleIa360LiteInteractive(record) { label: 'ia360_100m_schedule_confirmed', body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } await emitIa360N8nHandoff({ record, eventType: 'meeting_confirmed_calendar_zoom', @@ -2609,11 +3985,31 @@ async function handleIa360LiteInteractive(record) { titleSuffix: 'Llamada', notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', }); - await enqueueIa360Text({ - record, - label: 'ia360_100m_call_terminal_handoff', - body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', - }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } await emitIa360N8nHandoff({ record, eventType: 'call_requested', @@ -2840,6 +4236,10 @@ router.post('/webhook/whatsapp', async (req, res) => { } } + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + const { rows: pausedRows } = await pool.query( `SELECT id FROM coexistence.automation_executions WHERE wa_number=$1 AND contact_number=$2 @@ -2937,4 +4337,77 @@ router.get('/webhook/whatsapp', async (req, res) => { res.status(403).json({ error: 'Verification failed' }); }); +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + module.exports = { router }; From c0d4ea6d6fcbb1720fbe3cb68a16f4875eb7562d Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Fri, 5 Jun 2026 23:34:59 +0000 Subject: [PATCH 06/39] Add persona-first WhatsApp owner guardrails --- backend/src/routes/webhook.js | 234 ++++++++++++++++++++++++++++++++-- 1 file changed, 224 insertions(+), 10 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 4545bd4..10c1afb 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -279,6 +279,25 @@ async function upsertIa360SharedContact({ record, shared }) { return rows[0] || null; } +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; const { rows: pipeRows } = await pool.query( @@ -990,6 +1009,8 @@ async function notifyOwnerVcardCaptured({ record, shared }) { record, label: `owner_vcard_captured_${shared.contactNumber}`, messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, interactive: { type: 'list', header: { type: 'text', text: 'Contacto capturado' }, @@ -1389,14 +1410,28 @@ function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequen const customFields = contact?.custom_fields || {}; const name = contact?.name || targetContact; const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); - const copyStatus = hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; return { schema: 'persona_first_vcard.v1', request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, source: 'forgechat_b29_vcard_intake', received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), dry_run: true, - requires_human_approval: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), owner: { wa_id: IA360_OWNER_NUMBER, role: 'Alek', @@ -1439,13 +1474,13 @@ function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequen draft, }, approval: { - status: 'requires_alek', + status: approvalStatus, approved_by: '', approved_at: '', - reason: 'Requiere aprobación humana antes de cualquier envío externo.', + reason: approvalReason, }, guardrail: { - current_block: 'requires_human_approval', + current_block: currentBlock, external_send_allowed: false, allowed_recipient: 'owner_only', }, @@ -1460,6 +1495,13 @@ function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequen }; } +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { return [ 'Readout IA360 persona-first', @@ -1473,11 +1515,64 @@ function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payloa 'Borrador propuesto:', payload.sequence_candidate.draft, '', - 'Bloqueo actual: requiere aprobación humana; no hay envío externo al contacto.', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, ].join('\n'); } +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { const stage = flow.relationshipContext === 'no_contactar' ? 'No contactar' @@ -1532,6 +1627,8 @@ async function sendIa360SequenceSelector({ record, targetContact, contact, flowK record, label: `owner_sequence_selector_${targetContact}_${flowKey}`, messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, interactive: { type: 'list', header: { type: 'text', text: 'Elegir secuencia' }, @@ -1559,7 +1656,7 @@ async function handleIa360PersonaChoice({ record, targetContact, personaChoice } const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); const name = contact?.name || targetContact; if (!flow) { - await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.` }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); return; } await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); @@ -1574,10 +1671,40 @@ async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceI const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); const name = contact?.name || targetContact; if (!found) { - await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.` }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); return; } const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); if (hasUnresolvedIa360Placeholder(readout)) { @@ -1590,6 +1717,8 @@ async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceI toNumber: IA360_OWNER_NUMBER, label: `owner_sequence_readout_${sequence.id}`, body: readout, + targetContact, + ownerBudget: true, }); } @@ -1620,6 +1749,8 @@ async function handleIa360TerminalVcardChoice({ record, targetContact, terminalC toNumber: IA360_OWNER_NUMBER, label: `owner_terminal_${terminal.sequence.id}`, body: readout, + targetContact, + ownerBudget: true, }); return true; } @@ -1664,6 +1795,8 @@ async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_legacy_blocked', body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, }); } @@ -1677,11 +1810,16 @@ async function handleIa360SharedContacts(record) { toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_parse_failed', body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, }); return true; } for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } const saved = await upsertIa360SharedContact({ record, shared }); console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); const targetRecord = { @@ -1791,6 +1929,22 @@ async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); return; } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); const name = contact?.name || targetContact; const choice = String(pipeline || '').toLowerCase(); @@ -1888,12 +2042,66 @@ async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact const IA360_OWNER_NUMBER = '5213322638033'; +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + // Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la // fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound // (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro // numero, no colisiona). try/catch propio: nunca tumba el webhook. -async function sendOwnerInteractive({ record, interactive, label, messageBody }) { +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } const localId = await insertPendingRow({ @@ -1913,8 +2121,10 @@ async function sendOwnerInteractive({ record, interactive, label, messageBody }) // Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela // desde la rama owner, donde record.contact_number es Alek, no el prospecto). -async function sendIa360DirectText({ record, toNumber, body, label }) { +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } const localId = await insertPendingRow({ @@ -2849,6 +3059,10 @@ async function handleIa360LiteInteractive(record) { // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). const ownerReplyId = getInteractiveReplyId(record); if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } try { const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes From 75d82f2e0541377c3a7fdf7ba471218bfc199c03 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Fri, 5 Jun 2026 23:55:33 +0000 Subject: [PATCH 07/39] Add QA persona hint guard --- backend/src/routes/webhook.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 10c1afb..89bba77 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -243,8 +243,25 @@ function extractSharedContactsFromRecord(record) { } } +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + async function upsertIa360SharedContact({ record, shared }) { if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); const customFields = { staged: true, stage: 'Capturado / Por rutear', @@ -257,6 +274,7 @@ async function upsertIa360SharedContact({ record, shared }) { vcard_phone_raw: shared.phoneRaw || null, vcard_wa_id: shared.waId || null, email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), }; const tags = ['ia360-vcard', 'owner-intake', 'staged']; const { rows } = await pool.query( @@ -1948,6 +1966,17 @@ async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); const name = contact?.name || targetContact; const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } const targetRecord = { ...record, contact_number: targetContact, From f12325b7b0d6fe8fb2df212dcf1b93d96c0a1522 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Sat, 6 Jun 2026 02:13:39 +0000 Subject: [PATCH 08/39] Add IA360 memory store --- backend/src/routes/webhook.js | 719 +++++++++++++++++++++++++++++++++- 1 file changed, 713 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 89bba77..0b00bfd 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -204,6 +204,589 @@ async function mergeContactIa360State({ waNumber, contactNumber, tags = [], cust ); } +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + function extractSharedContactsFromRecord(record) { if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; try { @@ -879,7 +1462,19 @@ async function getActiveNonTerminalIa360Deal(record) { [pipelineId, record.wa_number, record.contact_number] ); const deal = rows[0]; - if (!deal) return null; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } const txt = String(record.message_body || '').toLowerCase(); // Reschedule intent: a prospect with an already-booked meeting who wants to move it // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). @@ -892,8 +1487,21 @@ async function getActiveNonTerminalIa360Deal(record) { // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin // esto el gate lo silenciaba estando en "Reunión agendada". const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); - // Always-terminal = won/lost → never auto-answer. - if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; @@ -999,7 +1607,8 @@ async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { intent: (agent && agent.intent) || null, action: (agent && agent.action) || null, extracted: (agent && agent.extracted) || {}, - last_message: record.message_body || '', + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, }; const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { method: 'POST', @@ -2475,6 +3084,12 @@ async function handleIa360FreeText(record) { const deal = await getActiveNonTerminalIa360Deal(record); if (!deal) return; // only inside an active, non-terminal IA360 funnel dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } const agent = await callIa360Agent({ record, stageName: deal.stage_name }); if (!agent || !agent.reply) { // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. @@ -4598,10 +5213,102 @@ const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + router.post('/internal/n8n-directive', async (req, res) => { // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) - const provided = req.get('X-IA360-Directive-Secret') || ''; - if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + if (!isIa360InternalAuthorized(req)) { return res.status(401).json({ ok: false, error: 'unauthorized' }); } try { From 9eebf4a6650cdbf9b26d674c76193540aa5ab1fa Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 00:23:50 +0000 Subject: [PATCH 09/39] checkpoint(ia360): scheduler recordatorios + gap #1/#5 webhook + add-to-calendar + templates (estado vivo en prod) Captura el working tree desplegado COPY-baked que vivia sin commitear desde f12325b: - services/ia360Reminders.js: scheduler 4 recordatorios por cita (1d/sameday_am/1h/starting), self-contained, E2E verificado al owner - index.js: wiring startReminderScheduler - routes/webhook.js: fixes gap #1 (bot no mudo agendado) y #5 (list_bookings reconcilia meeting_links) + anti-zombi cancel - routes/ia360-calendar.js: endpoints add-to-calendar (/api/r,/api/cal) + ia360_meeting_links - routes/ia360-intake.js, routes/templates.js, services/messageSender.js: soporte intake/templates/interactive audit Punto de restauracion previo al deploy de Revenue OS (V3). Co-Authored-By: Claude Opus 4.8 --- backend/src/index.js | 6 + backend/src/routes/ia360-calendar.js | 97 ++++++++ backend/src/routes/ia360-intake.js | 119 ++++++++++ backend/src/routes/templates.js | 19 +- backend/src/routes/webhook.js | 126 +++++++++- backend/src/services/ia360Reminders.js | 309 +++++++++++++++++++++++++ backend/src/services/messageSender.js | 4 +- 7 files changed, 665 insertions(+), 15 deletions(-) create mode 100644 backend/src/routes/ia360-calendar.js create mode 100644 backend/src/routes/ia360-intake.js create mode 100644 backend/src/services/ia360Reminders.js diff --git a/backend/src/index.js b/backend/src/index.js index 01eddfd..6a81f35 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -9,6 +9,8 @@ const pool = require('./db'); const { router: authRouter, authMiddleware, ensureTables } = require('./auth'); const { router: messagesRouter } = require('./routes/messages'); const { router: webhookRouter } = require('./routes/webhook'); +const { router: ia360IntakeRouter } = require('./routes/ia360-intake'); +const { router: ia360CalendarRouter } = require('./routes/ia360-calendar'); const { router: categoriesRouter } = require('./routes/categories'); const { router: contactFieldsRouter } = require('./routes/contactFields'); const { router: usersRouter } = require('./routes/users'); @@ -24,6 +26,7 @@ const { router: dashboardRouter } = require('./routes/dashboard'); const { router: pipelinesRouter } = require('./routes/pipelines'); const { startWorker: startMediaWorker, shutdown: shutdownMediaQueue } = require('./queue/mediaQueue'); const { startSendWorker, shutdownSendQueue } = require('./queue/sendQueue'); +const { startReminderScheduler } = require('./services/ia360Reminders'); const app = express(); const PORT = parseInt(process.env.PORT || '3001', 10); @@ -99,6 +102,8 @@ app.get('/health', (req, res) => res.json({ ok: true })); // Public routes (webhook from n8n — no auth) app.use('/api', webhookRouter); +app.use('/api', ia360IntakeRouter); // B-28 intake (publico, secreto compartido) +app.use('/api', ia360CalendarRouter); // IA360 add-to-calendar (publico, token) // Auth routes (public) app.use('/api', authRouter); @@ -134,6 +139,7 @@ async function start() { ); startMediaWorker(); startSendWorker(); + startReminderScheduler(); // IA360 per-meeting reminders (self-contained) // Stale-pause sweeper: mark paused automation executions that have outlived // their expires_at as error. Resume already inline-checks expires_at, so diff --git a/backend/src/routes/ia360-calendar.js b/backend/src/routes/ia360-calendar.js new file mode 100644 index 0000000..024ca09 --- /dev/null +++ b/backend/src/routes/ia360-calendar.js @@ -0,0 +1,97 @@ +'use strict'; + +// ============================================================================ +// IA360 — Endpoints públicos de calendario (add-to-calendar + redirect). +// Sin authMiddleware (el contacto los abre desde su teléfono). Token +// impredecible por cita (nunca eventId crudo). Solo lectura de +// coexistence.ia360_meeting_links. NUNCA envía mensajes. +// Montado en index.js zona PÚBLICA con app.use('/api', router): +// https://wa.geekstudio.dev/api/r/:token (redirect estable, base de botones) +// https://wa.geekstudio.dev/api/cal/:token(.ics) +// ============================================================================ + +const { Router } = require('express'); +const pool = require('../db'); + +const router = Router(); + +function icsStamp(d) { + // -> YYYYMMDDTHHMMSSZ (UTC) + return new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +function esc(s) { + return String(s == null ? '' : s).replace(/([\\;,])/g, '\\$1').replace(/\n/g, '\\n'); +} + +async function loadLink(token) { + if (!token || !/^[A-Za-z0-9_-]{16,64}$/.test(token)) return null; + const { rows } = await pool.query( + 'SELECT * FROM coexistence.ia360_meeting_links WHERE token = $1', [token]); + const link = rows[0]; + if (!link) return null; + if (link.expires_at && new Date(link.expires_at) < new Date()) return { expired: true }; + return link; +} + +// /api/r/:token -> 302 estable (base de los botones URL de los templates). +router.get('/r/:token', async (req, res) => { + try { + const link = await loadLink(req.params.token); + if (!link) return res.status(404).type('text/plain').send('Enlace no encontrado.'); + if (link.expired) return res.status(410).type('text/plain').send('Este enlace ya expiro.'); + if (link.kind === 'zoom' && link.zoom_join_url) return res.redirect(302, link.zoom_join_url); + return res.redirect(302, '/api/cal/' + encodeURIComponent(req.params.token)); + } catch (e) { console.error('[ia360-cal] /r error:', e.message); res.status(500).type('text/plain').send('Error.'); } +}); + +// /api/cal/:token.ics -> archivo ICS (definir ANTES de /cal/:token). +router.get('/cal/:token.ics', async (req, res) => { + try { + const link = await loadLink(req.params.token); + if (!link || link.expired) return res.status(404).type('text/plain').send('No disponible.'); + const uid = (link.event_id || link.token) + '@geekstudio.dev'; + const ics = [ + 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//TransformIA//IA360//ES', + 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', + 'UID:' + uid, + 'DTSTAMP:' + icsStamp(new Date()), + 'DTSTART:' + icsStamp(link.start_utc), + 'DTEND:' + icsStamp(link.end_utc), + 'SUMMARY:' + esc(link.summary || 'Reunion con Alek (TransformIA)'), + 'DESCRIPTION:' + esc(link.zoom_join_url ? ('Acceso Zoom: ' + link.zoom_join_url) : ''), + 'END:VEVENT', 'END:VCALENDAR' + ].join('\r\n'); + res.set('Content-Type', 'text/calendar; charset=utf-8'); + res.set('Content-Disposition', 'attachment; filename="reunion-ia360.ics"'); + res.send(ics); + } catch (e) { console.error('[ia360-cal] ics error:', e.message); res.status(500).type('text/plain').send('Error.'); } +}); + +// /api/cal/:token -> pagina con Google Calendar + descargar .ics. +router.get('/cal/:token', async (req, res) => { + try { + const link = await loadLink(req.params.token); + if (!link) return res.status(404).type('text/plain').send('Enlace no encontrado.'); + if (link.expired) return res.status(410).type('text/plain').send('Este enlace ya expiro.'); + const title = link.summary || 'Reunion con Alek (TransformIA)'; + const details = link.zoom_join_url ? ('Acceso Zoom: ' + link.zoom_join_url) : ''; + const gcal = 'https://calendar.google.com/calendar/render?action=TEMPLATE' + + '&text=' + encodeURIComponent(title) + + '&dates=' + icsStamp(link.start_utc) + '/' + icsStamp(link.end_utc) + + '&details=' + encodeURIComponent(details); + const icsUrl = '/api/cal/' + encodeURIComponent(req.params.token) + '.ics'; + res.type('text/html').send('' + + '' + + 'Agregar a mi calendario

Agregar reunion a tu calendario

' + + 'Agregar a Google Calendar' + + 'Descargar (Apple / Outlook / .ics)' + + ''); + } catch (e) { console.error('[ia360-cal] /cal error:', e.message); res.status(500).type('text/plain').send('Error.'); } +}); + +module.exports = { router }; diff --git a/backend/src/routes/ia360-intake.js b/backend/src/routes/ia360-intake.js new file mode 100644 index 0000000..f8eebd8 --- /dev/null +++ b/backend/src/routes/ia360-intake.js @@ -0,0 +1,119 @@ +'use strict'; + +// ============================================================================ +// B-28 — Endpoint de alta de contacto (S0 fuente web) +// ---------------------------------------------------------------------------- +// INVARIANTE DURO: captura != envio. Este endpoint SOLO escribe a +// coexistence.contacts con staged=true. NUNCA encola ni envia un mensaje. +// Prohibido importar/llamar: enqueueSend, insertPendingRow, resolveAccount, +// enqueueIa360Interactive/Flow/Text, sendOwnerInteractive, sendIa360DirectText, +// ni fetch hacia graph.facebook.com. Solo pool.query. +// +// Lo llama el workflow n8n "IA360 Alta Contacto Web (B-28)" por la URL publica +// https://wa.geekstudio.dev/api/ia360-intake (n8n y forgecrm estan en redes +// docker distintas). Auth = header secreto compartido X-IA360-Intake-Secret. +// Montado en index.js en la zona PUBLICA (sin authMiddleware), debajo de +// app.use('/api', webhookRouter). +// ============================================================================ + +const { Router } = require('express'); +const crypto = require('crypto'); +const pool = require('../db'); + +const router = Router(); + +const INTAKE_SECRET = process.env.IA360_INTAKE_SECRET || ''; +// Linea de negocio WABA IA360 = a quien le escribiria el contacto (wa_number). +const IA360_BUSINESS_WA_NUMBER = process.env.IA360_BUSINESS_WA_NUMBER || '5213321594582'; + +function timingSafeEqualStr(a, b) { + const ba = Buffer.from(String(a == null ? '' : a)); + const bb = Buffer.from(String(b == null ? '' : b)); + if (ba.length !== bb.length) return false; + return crypto.timingSafeEqual(ba, bb); +} + +function onlyDigits(s) { + return String(s == null ? '' : s).replace(/[^0-9]/g, ''); +} + +// Normaliza a E.164 (solo digitos, sin '+'), default Mexico movil (521 + 10). +function normalizeE164Mx(raw) { + let d = onlyDigits(raw); + if (!d) return null; + if (d.startsWith('00')) d = d.slice(2); // prefijo internacional 00 + if (d.length === 10) return '521' + d; // 10 digitos MX -> 521 + 10 + if (d.length === 12 && d.startsWith('52')) return '521' + d.slice(2); // 52 + 10 -> 521 + 10 + if (d.length === 13 && d.startsWith('521')) return d; // ya normalizado + if (d.length === 11 && d.startsWith('1')) return d; // US/CA: 1 + 10 + return d; // internacional u otro: deja los digitos crudos +} + +router.post('/ia360-intake', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe) + const provided = req.get('X-IA360-Intake-Secret') || ''; + if (!INTAKE_SECRET || !timingSafeEqualStr(provided, INTAKE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + + try { + const b = req.body || {}; + + // 2) Llave del contacto: contact_number normalizado a E.164 + const contactNumber = normalizeE164Mx(b.contact_number || b.telefono); + if (!contactNumber) { + return res.status(422).json({ + ok: false, + error: 'contact_number_required', + detail: 'sin telefono normalizable a E.164 no hay llave para coexistence.contacts' + }); + } + const waNumber = onlyDigits(b.wa_number) || IA360_BUSINESS_WA_NUMBER; + const name = (b.name || b.nombre || null); const profileName = (b.profile_name || name || null); /* FIX B-28: poblar profile_name (columna que muestra la UI/CRM) */ + + // 3) tags entrantes (array de strings) -> merge DISTINCT + const tags = Array.isArray(b.tags) + ? b.tags.filter((t) => typeof t === 'string' && t.trim()) + : []; + + // 4) custom_fields entrantes (objeto) -> staged SIEMPRE true (invariante B-28) + const cf = (b.custom_fields && typeof b.custom_fields === 'object' && !Array.isArray(b.custom_fields)) + ? { ...b.custom_fields } + : {}; + cf.staged = true; // capturado, FUERA del auto-ruteo R0 + if (!cf.stage) cf.stage = 'Capturado / Por rutear'; + if (!cf.captured_at) cf.captured_at = new Date().toISOString(); + cf.intake_source = cf.intake_source || 'b28-web-form'; + + // 5) Upsert idempotente (patron mergeContactIa360State + name). + // (xmax = 0) distingue INSERT nuevo (true) de UPDATE por conflicto (false). + const result = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), profile_name = COALESCE(coexistence.contacts.profile_name, EXCLUDED.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields, created_at, updated_at, (xmax = 0) AS inserted`, + [waNumber, contactNumber, name, profileName, JSON.stringify(tags), JSON.stringify(cf)] + ); + + const row = result.rows[0]; + const inserted = row.inserted === true; + delete row.inserted; + + // INVARIANTE B-28: cero outbound. No se encola ni envia nada. + return res.status(200).json({ ok: true, staged: true, deduped: !inserted, contact: row }); + } catch (err) { + console.error('[ia360-intake] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'intake_failed', detail: err && err.message }); + } +}); + +module.exports = { router }; diff --git a/backend/src/routes/templates.js b/backend/src/routes/templates.js index 80c8d1e..ffa8c6c 100644 --- a/backend/src/routes/templates.js +++ b/backend/src/routes/templates.js @@ -809,6 +809,23 @@ router.post('/templates/:id/test-send', requirePermission('template-builder'), a const { account, error } = await resolveAccount({ accountId: tpl.whatsapp_account_id }); if (error) return res.status(400).json({ error }); + // Guardrail (Sergio/Jorge bug): a template whose body has variables MUST + // receive every parameter, else WhatsApp renders the literal "{{1}}". + // Refuse the send instead of leaking a placeholder. + const bodyVars = extractVars(tpl.body); + const missingVars = bodyVars.filter(v => !String(sampleValues[v] ?? "").trim()); + if (missingVars.length > 0) { + return res.status(400).json({ + error: `Template "${tpl.name}" has body variables ${missingVars.map(v => `{{${v}}}`).join(", ")} without values; refusing to send (would render literal placeholders). Provide sampleValues.`, + }); + } + // Render the body so chat_history.message_body reflects what WhatsApp + // actually renders (real values, not "{{1}}"). + const renderedBody = bodyVars.reduce( + (acc, v) => acc.split(`{{${v}}}`).join(String(sampleValues[v])), + tpl.body || "" + ); + // Build components from sampleValues (sorted numerically by var index) const keys = Object.keys(sampleValues).sort((a, b) => +a - +b); const components = []; @@ -829,7 +846,7 @@ router.post('/templates/:id/test-send', requirePermission('template-builder'), a } const localId = await insertPendingRow({ - account, toNumber: to, messageType: 'template', messageBody: tpl.body || `Template: ${tpl.name}`, + account, toNumber: to, messageType: 'template', messageBody: renderedBody || tpl.body || `Template: ${tpl.name}`, }); await enqueueSend({ kind: 'template', diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 0b00bfd..ecee1ab 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -1033,17 +1033,36 @@ async function enqueueIa360Interactive({ record, label, messageBody, interactive if (resolved.duplicate || resolved.error) return false; const { account } = resolved; + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + const localId = await insertPendingRow({ account, toNumber: record.contact_number, messageType: 'interactive', - messageBody, + messageBody: auditBody, templateMeta: { ux: 'ia360_lite', label, ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, source: 'webhook_interactive_reply', }, + rawPayloadExtra: interactive, }); await enqueueSend({ kind: 'interactive', @@ -1502,9 +1521,11 @@ async function getActiveNonTerminalIa360Deal(record) { } return null; } - // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento - // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). - if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; return deal; } @@ -1563,6 +1584,11 @@ async function callIa360Agent({ record, stageName }) { const url = primaryIa360AgentUrl; if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); try { const res = await fetch(url, { method: 'POST', @@ -1573,12 +1599,25 @@ async function callIa360Agent({ record, stageName }) { history, source: 'forgechat-ia360-webhook', })), + signal: ia360AgentController.signal, }); if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } - return await res.json(); + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } } catch (err) { - console.error('[ia360-agent] error:', err.message); + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); return null; + } finally { + clearTimeout(ia360AgentTimer); } } @@ -2838,6 +2877,41 @@ async function loadIa360Bookings(contactNumber) { } } +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + // Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). // Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. async function appendIa360Booking({ waNumber, contactNumber, booking }) { @@ -2995,6 +3069,11 @@ async function removeIa360Booking({ waNumber, contactNumber, eventId }) { extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); return next; } @@ -3115,7 +3194,7 @@ async function handleIa360FreeText(record) { // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { - const bookings = await loadIa360Bookings(record.contact_number); + const bookings = await loadIa360BookingsForList(record.contact_number); console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); let body; if (!bookings.length) { @@ -3233,12 +3312,17 @@ async function handleIa360FreeText(record) { // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); if (agent.action === 'offer_slots' || agent.action === 'book') { // fall-through: el control sale del bloque "Reunión agendada" y continúa al // handler de offer_slots/book más abajo (no return). - } else { - // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek - // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". await enqueueIa360Text({ record, label: 'ia360_ai_reschedule_request', @@ -3282,6 +3366,12 @@ async function handleIa360FreeText(record) { // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; try { const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A @@ -4552,7 +4642,11 @@ async function handleIa360LiteInteractive(record) { // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape // que el path de día). try/catch terminal: si falla, log + texto de respaldo. - if (replyId === 'reslots') { + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { try { const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; let spread = null; @@ -4662,10 +4756,18 @@ async function handleIa360LiteInteractive(record) { dateStyle: 'medium', timeStyle: 'short', }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } await enqueueIa360Text({ record, label: 'ia360_100m_schedule_confirmed', - body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, }); // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. diff --git a/backend/src/services/ia360Reminders.js b/backend/src/services/ia360Reminders.js new file mode 100644 index 0000000..928510d --- /dev/null +++ b/backend/src/services/ia360Reminders.js @@ -0,0 +1,309 @@ +// IA360 meeting-reminder scheduler. Self-contained: does NOT touch webhook.js. +// Sends up to 4 Meta-template reminders per future meeting in +// coexistence.ia360_meeting_links, via the same messageSender + sendQueue path +// every other outbound uses. +// +// #1 reminder_1d -> ia360_os_meeting_reminder_1d (morning of the day BEFORE, >=09:00 local) +// #2 reminder_sameday_am-> ia360_os_meeting_reminder (morning of the SAME day, >=08:00 local) +// #3 reminder_1h -> ia360_os_meeting_reminder (~1h before start) +// #4 reminder_starting -> ia360_os_meeting_starting (T-0, short window around start) +// +// Idempotency: one timestamptz column per offset on ia360_meeting_links. Each +// reminder is claimed with an atomic `UPDATE ... WHERE col IS NULL RETURNING` +// BEFORE enqueue, so at-most-once per (meeting, offset) even with overlapping +// sweeps or multiple processes. + +const pool = require('../db'); +const { resolveAccount, insertPendingRow } = require('./messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); + +const IA360_WA_NUMBER = process.env.IA360_WA_NUMBER || '5213321594582'; +const TZ = 'America/Mexico_City'; +const TEMA_FALLBACK = 'los puntos que platicamos y tu siguiente paso con IA360'; + +// offset.key drives the window logic; col is the idempotency column. +const OFFSETS = [ + { key: '1d', col: 'reminder_1d_sent_at', template: 'ia360_os_meeting_reminder_1d', urlButton: true }, + { key: 'sameday_am', col: 'reminder_sameday_am_sent_at', template: 'ia360_os_meeting_reminder', urlButton: false }, + { key: '1h', col: 'reminder_1h_sent_at', template: 'ia360_os_meeting_reminder', urlButton: false }, + { key: 'starting', col: 'reminder_starting_sent_at', template: 'ia360_os_meeting_starting', urlButton: true }, +]; + +let lastTemplateSync = 0; + +// --------------------------------------------------------------------------- +// America/Mexico_City helpers (Mexico no longer observes DST, but we resolve +// the zone offset dynamically rather than hardcoding -6h). +// --------------------------------------------------------------------------- +function mxParts(date) { + const dtf = new Intl.DateTimeFormat('en-CA', { + timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }); + const p = Object.fromEntries( + dtf.formatToParts(date).filter(x => x.type !== 'literal').map(x => [x.type, x.value]) + ); + return { + year: +p.year, month: +p.month, day: +p.day, + hour: (+p.hour) % 24, minute: +p.minute, second: +p.second, + }; +} + +function pad(n) { return String(n).padStart(2, '0'); } +function mxDateStr(date) { const p = mxParts(date); return `${p.year}-${pad(p.month)}-${pad(p.day)}`; } + +// The day-before calendar date of a meeting, in local terms. +function mxDayBeforeStr(date) { + const p = mxParts(date); + const t = new Date(Date.UTC(p.year, p.month - 1, p.day, 12, 0, 0)); + t.setUTCDate(t.getUTCDate() - 1); + return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`; +} + +// "10:00 a.m." (normalize es-MX "a. m." -> "a.m.") +function fmtTimeMx(date) { + const s = new Intl.DateTimeFormat('es-MX', { + timeZone: TZ, hour: 'numeric', minute: '2-digit', hour12: true, + }).format(date); + return s.replace(/ | /g, ' ').replace(/[   ]/g, ' ').replace('a. m.', 'a.m.').replace('p. m.', 'p.m.').trim(); +} + +// "mié 10 jun 2026, 10:00 a.m." +function fmtLongMx(date) { + const p = mxParts(date); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: TZ, weekday: 'short' }).format(date).replace('.', ''); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: TZ, month: 'short' }).format(date).replace('.', ''); + return `${wd} ${p.day} ${mon} ${p.year}, ${fmtTimeMx(date)}`; +} + +// --------------------------------------------------------------------------- +// Window logic: is `offset.key` due NOW for a meeting starting at `start`? +// --------------------------------------------------------------------------- +function isDue(key, start, now) { + const np = mxParts(now); + const nowDate = mxDateStr(now); + switch (key) { + case '1d': + // morning of the day BEFORE the meeting, from 09:00 local + return nowDate === mxDayBeforeStr(start) && np.hour >= 9 && now < start; + case 'sameday_am': + // morning of the SAME day, from 08:00 local, still before the meeting + return nowDate === mxDateStr(start) && np.hour >= 8 && now < start; + case '1h': + return now >= new Date(start.getTime() - 60 * 60 * 1000) && now < start; + case 'starting': + // short window straddling T-0; >= sweep interval so we never skip it + return now >= new Date(start.getTime() - 3 * 60 * 1000) && + now <= new Date(start.getTime() + 10 * 60 * 1000); + default: + return false; + } +} + +// --------------------------------------------------------------------------- +// tema for {{2}} of ia360_os_meeting_reminder. Prefer a clean contact custom +// field; never inject the raw pipeline name. Fall back to a generic phrase. +// --------------------------------------------------------------------------- +async function deriveTema(contactNumber) { + const n = String(contactNumber || '').replace(/\D/g, ''); + if (!n) return TEMA_FALLBACK; + try { + const { rows } = await pool.query( + `SELECT custom_fields->>'tema' AS tema, custom_fields->>'pipeline' AS pipeline + FROM coexistence.contacts + WHERE contact_number = $1 + ORDER BY updated_at DESC + LIMIT 1`, + [n] + ); + const cand = (rows[0]?.tema || rows[0]?.pipeline || '').trim(); + if (cand && cand.length > 2) return cand; + } catch (err) { + console.error('[ia360-reminders] deriveTema error:', err.message); + } + return TEMA_FALLBACK; +} + +function bodyComp(texts) { + return { type: 'body', parameters: texts.map(t => ({ type: 'text', text: String(t) })) }; +} +// URL button with a dynamic {{1}} = token suffix. Index 0 in both templates. +function urlBtnComp(token) { + return { type: 'button', sub_type: 'url', index: '0', parameters: [{ type: 'text', text: String(token) }] }; +} + +async function lookupApprovedTemplate(name) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status + FROM coexistence.message_templates + WHERE name = $1 + ORDER BY (status = 'APPROVED') DESC, updated_at DESC + LIMIT 1`, + [name] + ); + return rows[0] || null; +} + +async function maybeKickTemplateSync() { + // ia360_os_meeting_starting may still be PENDING in Meta on a fresh env. + // Nudge the poller, throttled to once / 10 min. Best-effort. + if (Date.now() - lastTemplateSync < 10 * 60 * 1000) return; + lastTemplateSync = Date.now(); + try { + const { syncAllAccountTemplates } = require('../routes/templates'); + await syncAllAccountTemplates(); + } catch (err) { + console.error('[ia360-reminders] template sync nudge failed:', err.message); + } +} + +// Claim + send a single reminder. Returns a small status object. +async function fireReminder(account, m, off) { + const tpl = await lookupApprovedTemplate(off.template); + if (!tpl || tpl.status !== 'APPROVED') { + console.warn(`[ia360-reminders] template ${off.template} not APPROVED (status=${tpl?.status || 'missing'}) — degrade, skip offset=${off.key} token=${m.token}`); + if (off.key === 'starting') maybeKickTemplateSync(); + return { offset: off.key, skipped: 'template_not_approved' }; + } + + // Atomic at-most-once claim BEFORE enqueue. + const claim = await pool.query( + `UPDATE coexistence.ia360_meeting_links + SET ${off.col} = NOW() + WHERE token = $1 AND ${off.col} IS NULL + RETURNING token`, + [m.token] + ); + if (claim.rowCount === 0) return { offset: off.key, skipped: 'lost_claim' }; + + try { + const start = new Date(m.start_utc); + let components, body; + if (off.key === '1d') { + const d = fmtLongMx(start); + components = [bodyComp([d]), urlBtnComp(m.token)]; + body = `Recordatorio (mañana): ${d}`; + } else if (off.key === 'starting') { + const t = fmtTimeMx(start); + components = [bodyComp([t]), urlBtnComp(m.token)]; + body = `Tu reunión empieza ahora (${t}).`; + } else { + const t = fmtTimeMx(start); + const tema = await deriveTema(m.contact_number); + components = [bodyComp([t, tema])]; + body = `Recordatorio: reunión hoy a las ${t}. Objetivo: revisar ${tema}.`; + } + + const handlerFor = `reminder:${m.token}:${off.key}`; + const localId = await insertPendingRow({ + account, + toNumber: m.contact_number, + messageType: 'template', + messageBody: tpl.body || body, + templateMeta: { + ux: 'ia360_reminder', + offset: off.key, + token: m.token, + ia360_handler_for: handlerFor, + source: 'ia360Reminders', + template_name: tpl.name, + template_id: tpl.id, + }, + }); + + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: m.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + + console.log(`[ia360-reminders] enqueued offset=${off.key} token=${m.token} to=${m.contact_number} tpl=${tpl.name} local=${localId}`); + return { offset: off.key, sent: true, localId, handlerFor }; + } catch (err) { + // Claim already taken -> at-most-once preserved (no retry). Log loudly. + console.error(`[ia360-reminders] enqueue failed offset=${off.key} token=${m.token}: ${err.message}`); + return { offset: off.key, error: err.message }; + } +} + +// One sweep over future meetings with at least one un-sent offset. +async function runReminderSweep() { + const { account, error } = await resolveAccount({ fromPhoneNumber: IA360_WA_NUMBER }); + if (error || !account) { + console.error('[ia360-reminders] account resolve failed:', error || 'no account'); + return { ok: false, error: error || 'no account' }; + } + + const { rows } = await pool.query( + `SELECT token, contact_number, start_utc, summary, zoom_join_url, kind, + reminder_1d_sent_at, reminder_sameday_am_sent_at, + reminder_1h_sent_at, reminder_starting_sent_at + FROM coexistence.ia360_meeting_links + WHERE start_utc IS NOT NULL + AND start_utc > NOW() - INTERVAL '15 minutes' + AND (reminder_1d_sent_at IS NULL + OR reminder_sameday_am_sent_at IS NULL + OR reminder_1h_sent_at IS NULL + OR reminder_starting_sent_at IS NULL)` + ); + + const now = new Date(); + const results = []; + for (const m of rows) { + const start = new Date(m.start_utc); + for (const off of OFFSETS) { + if (m[off.col]) continue; // already sent + if (!isDue(off.key, start, now)) continue; + results.push(await fireReminder(account, m, off)); + } + } + const sent = results.filter(r => r.sent).length; + if (sent > 0) console.log(`[ia360-reminders] sweep: ${rows.length} candidate meeting(s), ${sent} reminder(s) enqueued`); + return { ok: true, candidates: rows.length, results }; +} + +async function ensureReminderColumns() { + await pool.query( + `ALTER TABLE coexistence.ia360_meeting_links + ADD COLUMN IF NOT EXISTS reminder_1d_sent_at timestamptz, + ADD COLUMN IF NOT EXISTS reminder_sameday_am_sent_at timestamptz, + ADD COLUMN IF NOT EXISTS reminder_1h_sent_at timestamptz, + ADD COLUMN IF NOT EXISTS reminder_starting_sent_at timestamptz` + ); + await pool.query( + `CREATE INDEX IF NOT EXISTS idx_ia360_meeting_links_reminders + ON coexistence.ia360_meeting_links (start_utc) + WHERE reminder_1d_sent_at IS NULL + OR reminder_sameday_am_sent_at IS NULL + OR reminder_1h_sent_at IS NULL + OR reminder_starting_sent_at IS NULL` + ); +} + +function startReminderScheduler() { + const MS = parseInt(process.env.IA360_REMINDER_INTERVAL_MS || '', 10) || 5 * 60 * 1000; + ensureReminderColumns().catch(e => console.error('[ia360-reminders] ensure columns failed:', e.message)); + + let running = false; + const tick = async () => { + if (running) return; // in-process overlap guard (claim handles cross-process) + running = true; + try { await runReminderSweep(); } + catch (e) { console.error('[ia360-reminders] sweep error:', e.message); } + finally { running = false; } + }; + + setTimeout(tick, 45 * 1000).unref(); // catch-up shortly after startup + setInterval(tick, MS).unref(); // steady-state every ~5 min + console.log(`[ia360-reminders] scheduler started, interval=${MS}ms, wa=${IA360_WA_NUMBER}`); +} + +module.exports = { + startReminderScheduler, + runReminderSweep, + ensureReminderColumns, + // exported for tests / introspection + isDue, fmtTimeMx, fmtLongMx, mxDateStr, mxDayBeforeStr, deriveTema, +}; diff --git a/backend/src/services/messageSender.js b/backend/src/services/messageSender.js index 3228aa5..caadda9 100644 --- a/backend/src/services/messageSender.js +++ b/backend/src/services/messageSender.js @@ -45,7 +45,7 @@ async function resolveAccount({ accountId, fromPhoneNumber }) { * Insert an optimistic chat_history row that the UI shows as "sending…". * Returns the local message_id used so caller can correlate later updates. */ -async function insertPendingRow({ account, toNumber, messageType, messageBody, mediaUrl = null, mediaMime = null, templateMeta = null, contextMessageId = null }) { +async function insertPendingRow({ account, toNumber, messageType, messageBody, mediaUrl = null, mediaMime = null, templateMeta = null, contextMessageId = null, rawPayloadExtra = null }) { const messageId = localMessageId(); await pool.query( `INSERT INTO coexistence.chat_history @@ -61,7 +61,7 @@ async function insertPendingRow({ account, toNumber, messageType, messageBody, m String(toNumber).replace(/\D/g, ''), messageType, messageBody || null, - JSON.stringify({ origin: 'outbound', queued_at: new Date().toISOString() }), + JSON.stringify({ origin: 'outbound', queued_at: new Date().toISOString(), ...(rawPayloadExtra ? { interactive: rawPayloadExtra } : {}) }), mediaUrl, mediaMime, templateMeta ? JSON.stringify(templateMeta) : null, From d10dea5bcaa6211c100acd78c1461c22f1871a12 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 17:57:37 +0000 Subject: [PATCH 10/39] =?UTF-8?q?checkpoint(ia360):=20Revenue=20OS=20P5=20?= =?UTF-8?q?webhook=20handler=20(estado=20vivo=20en=20prod)=20=E2=80=94=20g?= =?UTF-8?q?ate=5Fslots=20EN=20CUARENTENA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protege el trabajo vivo del modelo COPY-bake (host editado, nunca commiteado). Incluye handleRevenueOsButton (Pipeline 5, maquina de estados ia360_revenue_state). BUG VIVO conocido y EN CUARENTENA (no se toca en este commit): - Al clicar Si, ver horarios el sub-gate de Hablar con Alek reusa los IDs globales gate_slots_yes/no y dispara ia360_os_revenue_ahora_no (despedida) justo antes de los slots. Fix sugerido: IDs namespaced rev_gate_yes/no + gateo estricto por ia360_revenue_state. Backup: webhook.js.bak-pre-revenueos-20260609T002702 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/webhook.js | 351 +++++++++++++++++++++++++++++++++- 1 file changed, 350 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index ecee1ab..5efc07e 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -1006,6 +1006,320 @@ async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes return { id: existing.id, moved: shouldMove }; } +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + async function resolveIa360Outbound(record, dedupSuffix = '') { // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. @@ -3944,6 +4258,11 @@ async function handleIa360LiteInteractive(record) { const answer = String(record.message_body || '').trim().toLowerCase(); const replyId = getInteractiveReplyId(record); + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). @@ -5218,8 +5537,14 @@ router.post('/webhook/whatsapp', async (req, res) => { continue; // do not also fire fresh triggers } await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). - handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); await evaluateTriggers(record); } catch (triggerErr) { console.error('[webhook] Trigger evaluation error:', triggerErr.message); @@ -5355,6 +5680,30 @@ router.post('/internal/ia360-memory/lookup', async (req, res) => { } }); +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + router.post('/internal/ia360-memory/capture', async (req, res) => { if (!isIa360InternalAuthorized(req)) { return res.status(401).json({ ok: false, error: 'unauthorized' }); From 0dfcf542cd410327db24db8835b5f2ff10dd7d27 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 19:14:53 +0000 Subject: [PATCH 11/39] feat(ia360): canary reversible Brain v2 en webhook.js (allowlist owner + /sim + handback) --- backend/src/routes/webhook.js | 104 ++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 5efc07e..4100141 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -5347,6 +5347,101 @@ async function handleIa360LiteInteractive(record) { * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. * No auth required — called by internal n8n instance. */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + router.post('/webhook/whatsapp', async (req, res) => { try { // Authenticity: this endpoint is necessarily unauthenticated (public), so @@ -5473,6 +5568,15 @@ router.post('/webhook/whatsapp', async (req, res) => { if (incomingRecords.length > 0) { for (const record of incomingRecords) { try { + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. From 5c8fac5b53304e29aee71a0fc55301d0021b26dd Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 19:18:11 +0000 Subject: [PATCH 12/39] fix(ia360): canary Brain v2 timeout 30s->90s (gpt-5 responder es lento; fire-and-forget no afecta ACK) --- backend/src/routes/webhook.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 4100141..474408e 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -5381,7 +5381,7 @@ function ia360BrainV2CanaryEligible(record) { async function callBrainV2({ contactWaNumber, message, forceActor }) { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 30000); + const timer = setTimeout(() => controller.abort(), 90000); try { const res = await fetch(IA360_BRAIN_V2_URL, { method: 'POST', From cea0953ef9bdc22e0f63fafbe017898407df9c69 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 19:50:14 +0000 Subject: [PATCH 13/39] feat(ia360): validador pre-Meta de templates (bloquea #132000/#132012) en sendQueue + fallback texto owner-pipe --- backend/src/integrations/templateValidator.js | 162 ++++++++++++++++++ backend/src/queue/sendQueue.js | 18 ++ backend/src/routes/webhook.js | 27 +++ 3 files changed, 207 insertions(+) create mode 100644 backend/src/integrations/templateValidator.js diff --git a/backend/src/integrations/templateValidator.js b/backend/src/integrations/templateValidator.js new file mode 100644 index 0000000..5abd185 --- /dev/null +++ b/backend/src/integrations/templateValidator.js @@ -0,0 +1,162 @@ +// Template send validator. +// +// Guards against the two Meta send errors that silently burn template sends: +// #132000 -> parameter count mismatch (body / header text / URL button) +// #132012 -> format mismatch (media header missing, or media sent on a +// non-media header) +// +// It validates the OUTGOING `components` payload (Meta send format) against the +// AUTHORITATIVE template spec, in this authority order: +// 1. Live Graph API spec (cached per WABA, short TTL) +// 2. Locally synced coexistence.message_templates row (fallback if Meta +// cannot be reached -- e.g. transient network error) +// 3. If NEITHER is available -> hard block (fail-closed; never let an +// unverifiable template reach Meta). +// +// Pure helpers are exported so they can be unit-tested without network/DB. + +const pool = require('../db'); +const { listTemplates } = require('./metaTemplates'); + +const SPEC_TTL_MS = 5 * 60 * 1000; +const _cache = new Map(); // wabaId -> { at, byKey: Map('name::lang' -> spec) } + +function distinctVars(text) { + const s = new Set(); + for (const m of String(text || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) s.add(m[1]); + return s.size; +} + +// Structural requirements from a Meta spec (components[] as Graph API returns). +function requirementsFromMetaSpec(spec) { + const req = { bodyParams: 0, headerFormat: 'NONE', headerNeedsMedia: false, headerTextParams: 0, urlButtons: [] }; + for (const c of (spec && spec.components) || []) { + const type = String(c.type || '').toUpperCase(); + if (type === 'BODY') { + req.bodyParams = distinctVars(c.text); + } else if (type === 'HEADER') { + req.headerFormat = String(c.format || 'TEXT').toUpperCase(); + if (['IMAGE', 'VIDEO', 'DOCUMENT'].includes(req.headerFormat)) req.headerNeedsMedia = true; + else if (req.headerFormat === 'TEXT') req.headerTextParams = distinctVars(c.text); + } else if (type === 'BUTTONS') { + (c.buttons || []).forEach((b, i) => { + if (String(b.type || '').toUpperCase() === 'URL' && distinctVars(b.url) > 0) { + req.urlButtons.push({ index: i, params: distinctVars(b.url) }); + } + }); + } + } + return req; +} + +// Same, derived from a locally synced message_templates row (fallback authority). +function requirementsFromLocalRow(row) { + const req = { bodyParams: distinctVars(row.body), headerFormat: String(row.header_type || 'NONE').toUpperCase(), headerNeedsMedia: false, headerTextParams: 0, urlButtons: [] }; + if (['IMAGE', 'VIDEO', 'DOCUMENT'].includes(req.headerFormat)) req.headerNeedsMedia = true; + else if (req.headerFormat === 'TEXT') req.headerTextParams = distinctVars(row.header_text); + let btns = row.buttons; + if (typeof btns === 'string') { try { btns = JSON.parse(btns); } catch { btns = []; } } + (btns || []).forEach((b, i) => { + if (String(b.type || '').toUpperCase() === 'URL') { + const url = b.value || b.url || ''; + if (distinctVars(url) > 0) req.urlButtons.push({ index: i, params: distinctVars(url) }); + } + }); + return req; +} + +// Inspect the outgoing components payload (Meta send format) -> provided shape. +function shapeOfComponents(components) { + const out = { bodyParams: 0, headerParams: 0, headerMedia: false, urlButtons: [] }; + for (const c of components || []) { + const type = String(c.type || '').toLowerCase(); + if (type === 'body') { + out.bodyParams = (c.parameters || []).length; + } else if (type === 'header') { + out.headerParams = (c.parameters || []).length; + out.headerMedia = (c.parameters || []).some(p => ['image', 'video', 'document'].includes(String(p.type || '').toLowerCase())); + } else if (type === 'button') { + if (String(c.sub_type || '').toLowerCase() === 'url') out.urlButtons.push({ index: Number(c.index), params: (c.parameters || []).length }); + } + } + return out; +} + +// Compare requirements vs outgoing components. Returns { valid, errors[] }. +function validateAgainstRequirements(req, components) { + const errors = []; + const got = shapeOfComponents(components); + if (got.bodyParams !== req.bodyParams) { + errors.push(`body params: template expects ${req.bodyParams}, got ${got.bodyParams} (#132000)`); + } + if (req.headerNeedsMedia && !got.headerMedia) { + errors.push(`header ${req.headerFormat} requires media but none was provided (#132012)`); + } + if (!req.headerNeedsMedia && got.headerMedia) { + errors.push(`header is ${req.headerFormat} but a media header param was provided (#132012)`); + } + if (req.headerFormat === 'TEXT' && (req.headerTextParams > 0 || got.headerParams > 0) && req.headerTextParams !== got.headerParams) { + errors.push(`header text params: template expects ${req.headerTextParams}, got ${got.headerParams} (#132000)`); + } + for (const ub of req.urlButtons) { + const provided = got.urlButtons.find(g => g.index === ub.index); + if (!provided) errors.push(`url button index ${ub.index} requires ${ub.params} param(s) but none was provided (#132000)`); + else if (provided.params !== ub.params) errors.push(`url button index ${ub.index}: expects ${ub.params}, got ${provided.params} (#132000)`); + } + return { valid: errors.length === 0, errors, requirements: req }; +} + +async function fetchMetaSpecs(account) { + const key = String(account.wabaId); + const now = Date.now(); + const cached = _cache.get(key); + if (cached && (now - cached.at) < SPEC_TTL_MS) return cached.byKey; + const list = await listTemplates(account.wabaId, account.accessToken, { fields: 'name,language,status,components,id', limit: 200 }); + const byKey = new Map(); + for (const t of list) byKey.set(`${t.name}::${t.language}`, t); + _cache.set(key, { at: now, byKey }); + return byKey; +} + +async function getRequirements(account, name, language) { + try { + const byKey = await fetchMetaSpecs(account); + let spec = byKey.get(`${name}::${language}`); + if (!spec) { for (const [, v] of byKey) if (v.name === name) { spec = v; break; } } + if (spec) return { source: 'meta', req: requirementsFromMetaSpec(spec) }; + } catch (err) { + console.warn('[tpl-validator] Meta fetch failed, falling back to local synced row:', err.message); + } + try { + const { rows } = await pool.query( + `SELECT name, header_type, header_text, body, buttons + FROM coexistence.message_templates + WHERE name = $1 AND status = 'APPROVED' + ORDER BY updated_at DESC LIMIT 1`, + [name] + ); + if (rows[0]) return { source: 'local', req: requirementsFromLocalRow(rows[0]) }; + } catch (err) { + console.warn('[tpl-validator] local row fetch failed:', err.message); + } + return { source: 'none', req: null }; +} + +// Main entry. Returns { valid, errors[], source, requirements }. +async function validateTemplateSend(account, name, language, components) { + const { source, req } = await getRequirements(account, name, language); + if (!req) { + return { valid: false, source, requirements: null, errors: [`no template spec available for "${name}" (neither Meta nor local synced row) -- blocking to avoid garbage to Meta`] }; + } + const r = validateAgainstRequirements(req, components); + return { valid: r.valid, errors: r.errors, requirements: r.requirements, source }; +} + +module.exports = { + validateTemplateSend, + validateAgainstRequirements, + requirementsFromMetaSpec, + requirementsFromLocalRow, + shapeOfComponents, + distinctVars, +}; diff --git a/backend/src/queue/sendQueue.js b/backend/src/queue/sendQueue.js index e8a8fec..0dc18fa 100644 --- a/backend/src/queue/sendQueue.js +++ b/backend/src/queue/sendQueue.js @@ -9,6 +9,7 @@ const { getAccountWithToken } = require('../routes/whatsappAccounts'); const { sendText, sendTemplate, sendMedia, sendInteractive, sendLocation, sendContacts, sendReaction } = require('../integrations/metaSend'); const { markSent, markFailed } = require('../services/messageSender'); const { markAccountHealth, classifyMetaError } = require('../services/accountHealth'); +const { validateTemplateSend } = require('../integrations/templateValidator'); const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379'; const QUEUE_NAME = 'forgecrm-send'; @@ -59,6 +60,23 @@ async function processJob(job) { to, }; + // Universal pre-Meta guard: block any template whose outgoing component shape + // (body/header-media/URL-button param counts) does not match the + // Meta-registered spec. This is the last choke point every template send + // funnels through, so it catches mismatches from ALL paths (owner pipe, + // reminders, automation, broadcasts, test-send) before they reach Meta and + // burn as #132000 / #132012. Thrown as skipRetry so the optimistic row is + // marked failed once, with the exact reason, and nothing hits Meta. + if (kind === 'template') { + const v = await validateTemplateSend(account, payload.name, payload.languageCode, payload.components); + if (!v.valid) { + const e = new Error(`template "${payload.name}" blocked pre-Meta: ${v.errors.join('; ')} [authority=${v.source}]`); + e.skipRetry = true; + e.templateBlocked = true; + throw e; + } + } + let result; try { if (kind === 'text') { diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 474408e..2620b2f 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -1525,6 +1525,16 @@ async function resolveTemplateHeaderMediaId(tpl, account) { return sync.meta_media_id; } +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + async function buildIa360TemplateComponents(tpl, account, record) { const components = []; const headerType = String(tpl.header_type || 'NONE').toUpperCase(); @@ -1605,6 +1615,23 @@ async function enqueueIa360Template({ record, label, templateName, templateId = console.error('[ia360-owner-pipe] template components error:', err.message); return { ok: false, status: 'template_components_error', error: err.message }; } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } const handlerFor = `${record.message_id}:${label}`; const localId = await insertPendingRow({ account, From f8160424c7a971231a2e9fa6b5cc1009645d15a7 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 22:26:58 +0000 Subject: [PATCH 14/39] =?UTF-8?q?feat(ia360):=20approve-send=20=C3=BAltimo?= =?UTF-8?q?=20metro=20P0=20=E2=80=94=20tarjeta=20de=20aprobaci=C3=B3n=20po?= =?UTF-8?q?st-readout=20+=20handler=20owner=5Fapprove=5Fsend=20con=20gate?= =?UTF-8?q?=20de=20allowlist=20de=20prueba?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tras el readout persona-first, el owner recibe tarjeta: Aprobar y enviar / Editar copy / Solo guardar / No contactar / Tomar manual (patrón de la tarjeta de cancelación). - handler owner_approve_send valida: no-owner/no-bot, contexto de tarjeta, estado persistido coincide con el último readout, do_not_contact, copy no bloqueado, allowlist IA360_APPROVE_SEND_ALLOWLIST (vacía = NO envía), ventana 24h (texto dentro; template validado vía templateValidator fuera). - Egress único vía messageSender; persiste approved_by/approved_at/sent_at/ send_status/outbound_message_id; avanza deal a Diagnóstico enviado. - E2E approve-send-e2e.sh (fase negativa 12/12 contra producción). Co-Authored-By: Claude Opus 4.8 (1M context) --- approve-send-e2e.sh | 123 +++++++++++++++ backend/src/routes/webhook.js | 275 +++++++++++++++++++++++++++++++++- 2 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 approve-send-e2e.sh diff --git a/approve-send-e2e.sh b/approve-send-e2e.sh new file mode 100644 index 0000000..334fe23 --- /dev/null +++ b/approve-send-e2e.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E — APPROVE-SEND ("último metro" P0): vCard → persona → secuencia → readout +# → tarjeta de aprobación → Aprobar y enviar. +# Fase A (default): gate cerrado (allowlist vacía) → demuestra que NO envía. +# Fase B (TESTNUM en allowlist): envía el opener al contacto de prueba. +# Uso: bash approve-send-e2e.sh [positivo] +# ============================================================================ +set -uo pipefail + +TESTNUM="${1:?numero de contacto de prueba requerido}" +MODE="${2:-negativo}" + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="123456789" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado='$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "$2" "contiene:$3"; fi; } + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } +WAMID_BASE="wamid.e2e.approvesend.$(ts)" + +owner_msg_id_by_label(){ # $1=label → message_id de la última saliente al owner con ese label + psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} +owner_body_by_label(){ + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} + +inject_interactive(){ # $1=reply_id $2=context_msg_id $3=title + local body + body=$(cat </dev/null +psql_q "DELETE FROM coexistence.chat_history WHERE contact_number='$TESTNUM'" >/dev/null +psql_q "DELETE FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$TESTNUM'" >/dev/null +ok "estado limpio" + +echo "=== STEP 1 — owner comparte vCard del contacto de prueba ===" +VCARD_BODY="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"e2e\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID_NUM\"},\"contacts\":[{\"profile\":{\"name\":\"Alek\"},\"wa_id\":\"$OWNER\"}],\"messages\":[{\"from\":\"$OWNER\",\"id\":\"$WAMID_BASE.vcard\",\"timestamp\":\"$(ts)\",\"type\":\"contacts\",\"contacts\":[{\"name\":{\"formatted_name\":\"Contacto Prueba ApproveSend\",\"first_name\":\"Contacto\"},\"phones\":[{\"phone\":\"+$TESTNUM\",\"wa_id\":\"$TESTNUM\",\"type\":\"CELL\"}]}]}]},\"field\":\"messages\"}]}]}" +ST=$(post_webhook "$VCARD_BODY"); chk "webhook vCard HTTP" "$ST" "200" +sleep 6 +CARD1=$(owner_msg_id_by_label "owner_vcard_captured_$TESTNUM") +[ -n "$CARD1" ] && ok "tarjeta de captura enviada al owner (msg_id=$CARD1)" || bad "tarjeta captura" "" "message_id" + +echo "=== STEP 2 — owner elige persona: Beta / amigo ===" +ST=$(inject_interactive "owner_pipe:$TESTNUM:persona_beta" "$CARD1" "Beta / amigo"); chk "webhook persona HTTP" "$ST" "200" +sleep 6 +CARD2=$(owner_msg_id_by_label "owner_sequence_selector_${TESTNUM}_persona_beta") +[ -n "$CARD2" ] && ok "selector de secuencias enviado (msg_id=$CARD2)" || bad "selector secuencias" "" "message_id" + +echo "=== STEP 3 — owner elige secuencia: beta_architectura ===" +ST=$(inject_interactive "owner_seq:$TESTNUM:beta_architectura" "$CARD2" "Validar arquitectura"); chk "webhook secuencia HTTP" "$ST" "200" +sleep 7 +READOUT=$(owner_body_by_label "owner_sequence_readout_beta_architectura") +chk_has "readout persona-first al owner" "$READOUT" "Secuencia elegida: Validar arquitectura IA360" +CARD3=$(owner_msg_id_by_label "owner_approve_card_${TESTNUM}_beta_architectura") +[ -n "$CARD3" ] && ok "TARJETA DE APROBACION enviada (msg_id=$CARD3)" || bad "tarjeta aprobacion" "" "message_id" +CARD3_BODY=$(psql_q "SELECT message_body FROM coexistence.chat_history WHERE message_id='$CARD3' LIMIT 1") +chk_has "tarjeta de aprobacion correcta" "$CARD3_BODY" "IA360: aprobar" + +if [ "$MODE" = "positivo" ]; then + echo "=== STEP 4P — abrir ventana 24h: inbound simulado del contacto de prueba ===" + INBODY="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"e2e\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID_NUM\"},\"contacts\":[{\"profile\":{\"name\":\"Contacto Prueba\"},\"wa_id\":\"$TESTNUM\"}],\"messages\":[{\"from\":\"$TESTNUM\",\"id\":\"$WAMID_BASE.inwindow\",\"timestamp\":\"$(ts)\",\"type\":\"text\",\"text\":{\"body\":\"hola\"}}]},\"field\":\"messages\"}]}]}" + ST=$(post_webhook "$INBODY"); chk "webhook inbound contacto HTTP" "$ST" "200" + sleep 8 +fi + +echo "=== STEP 5 — owner tap: Aprobar y enviar ===" +ST=$(inject_interactive "owner_approve_send:$TESTNUM:beta_architectura" "$CARD3" "Aprobar y enviar"); chk "webhook aprobar HTTP" "$ST" "200" +sleep 10 + +if [ "$MODE" = "positivo" ]; then + echo "=== STEP 6P — el CONTACTO recibe el opener + stage avanzado ===" + OPENER=$(psql_q "SELECT message_body||' | status='||status FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$TESTNUM' AND template_meta->>'label'='ia360_seq_opener_beta_architectura' ORDER BY id DESC LIMIT 1") + chk_has "opener RENDERIZADO en chat_history del contacto" "$OPENER" "soy la IA de Alek" + echo " [RENDER] $OPENER" + STAGE=$(psql_q "SELECT s.name FROM coexistence.deals d JOIN coexistence.pipeline_stages s ON s.id=d.stage_id JOIN coexistence.pipelines p ON p.id=d.pipeline_id WHERE p.name='IA360 WhatsApp Revenue Pipeline' AND d.contact_number='$TESTNUM' ORDER BY d.updated_at DESC NULLS LAST, d.id DESC LIMIT 1") + chk "stage del deal avanzado" "$STAGE" "Diagnóstico enviado" + APPROVED=$(psql_q "SELECT custom_fields->>'approved_by' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$TESTNUM'") + chk "approved_by persistido" "$APPROVED" "$OWNER" + DONE_MSG=$(owner_body_by_label "owner_approve_send_done") + chk_has "confirmacion al owner" "$DONE_MSG" "Diagnóstico enviado" +else + echo "=== STEP 6N — GATE: sin allowlist NO envia ===" + BLOCKED=$(owner_body_by_label "owner_approve_send_blocked") + chk_has "owner notificado del bloqueo por allowlist" "$BLOCKED" "IA360_APPROVE_SEND_ALLOWLIST" + SENT_TO_CONTACT=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$TESTNUM'") + chk "cero mensajes salientes al contacto" "$SENT_TO_CONTACT" "0" + REASON=$(psql_q "SELECT custom_fields->>'ia360_approve_send_blocked_reason' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$TESTNUM'") + chk "razon de bloqueo persistida" "$REASON" "not_in_test_allowlist" +fi + +echo "" +echo "=== RESULTADO: PASS=$PASS FAIL=$FAIL (modo=$MODE) ===" +[ "$FAIL" = "0" ] && exit 0 || exit 1 diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 2620b2f..864ec8a 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -5,7 +5,7 @@ const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); const { enqueueMediaDownload } = require('../queue/mediaQueue'); -const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); const { enqueueSend } = require('../queue/sendQueue'); const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); @@ -2727,6 +2727,258 @@ async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceI targetContact, ownerBudget: true, }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { + text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, + }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows: [ + { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ], + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + const allow = ia360ApproveSendAllowlist(); + if (!allow.length || !allow.includes(normalizePhone(targetContact))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); + // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phone_number_id, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + const sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(`${record.message_id}:direct:${targetContact}`); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); } async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { @@ -4151,6 +4403,27 @@ async function handleIa360LiteInteractive(record) { await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); return; } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); return; From b1e8ebb600ec077b01cad07016af0f412386b167 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 23:12:17 +0000 Subject: [PATCH 15/39] =?UTF-8?q?fix(ia360):=20approve-send=20=E2=80=94=20?= =?UTF-8?q?wildcard=20*=20en=20allowlist=20(aprobaci=C3=B3n=20del=20owner?= =?UTF-8?q?=20autoriza)=20+=20fix=20ventana=2024h=20(account.phoneNumberId?= =?UTF-8?q?=20camelCase)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IA360_APPROVE_SEND_ALLOWLIST=* : la aprobación explícita del owner desde la tarjeta autoriza el envío a cualquier contacto (pedido de Alek en vivo). - El chequeo de ventana pasaba account.phone_number_id (undefined, el objeto es camelCase) → siempre fuera de ventana; ahora account.phoneNumberId. - E2E vivo: opener cliente_expansion entregado a contacto de prueba, deal 31 a Diagnóstico enviado, approved_by/sent_at/wamid persistidos. - Template ia360_aliado_mapa_colaboracion (id 43) SUBMITTED a Meta para envíos en frío fuera de ventana. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/webhook.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 864ec8a..1c27416 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -2865,8 +2865,10 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } } // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); const allow = ia360ApproveSendAllowlist(); - if (!allow.length || !allow.includes(normalizePhone(targetContact))) { + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); } @@ -2875,7 +2877,7 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); - const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phone_number_id, contactNumber: targetContact }); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); const insideWindow = secs != null && secs < 23.5 * 3600; const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; let sendResult = { ok: false, status: 'not_sent', error: null }; From 2f6879747df42ee83118ba1cb11f0c6f2c6ce80b Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 23:43:21 +0000 Subject: [PATCH 16/39] =?UTF-8?q?feat(ia360):=20expediente=20del=20contact?= =?UTF-8?q?o=20al=20agente=20=E2=80=94=20callIa360Agent=20incluye=20memory?= =?UTF-8?q?=20(facts+events)=20en=20el=20payload;=20el=20monolito=20lo=20i?= =?UTF-8?q?nyecta=20al=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El agente respondia sin contexto del contacto aunque la memoria existiera: buildIa360AgentPayload solo mandaba texto+stage+historial. Ahora carga lookupIa360MemoryContext (best-effort) y lo adjunta como payload.memory; el nodo Build Prompt del monolito (republicado via API v1) lo convierte en bloque de expediente para personalizar sin re-preguntar lo ya sabido. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/webhook.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 1c27416..206a865 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -1918,6 +1918,16 @@ async function callIa360Agent({ record, stageName }) { ); const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); @@ -1934,12 +1944,15 @@ async function callIa360Agent({ record, stageName }) { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify(buildIa360AgentPayload({ - record, - stageName, - history, - source: 'forgechat-ia360-webhook', - })), + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), signal: ia360AgentController.signal, }); if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } From 1660367f5c1160b516bb5b3e6a0e83d4bd897ae7 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 9 Jun 2026 23:57:12 +0000 Subject: [PATCH 17/39] fix(ia360): expediente del agente sube de 8 a 12 facts (el menu cargado no debe desplazar el contexto base) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/webhook.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 206a865..9fcb4fa 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -1923,7 +1923,7 @@ async function callIa360Agent({ record, stageName }) { let agentMemory = null; try { const memContact = await loadIa360ContactContext(record); - agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 12 }); } catch (memErr) { console.error('[ia360-agent] memory lookup failed:', memErr.message); } From 0cd223e544e8311644fed9989d5feacb1104a326 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 10 Jun 2026 00:15:35 +0000 Subject: [PATCH 18/39] =?UTF-8?q?feat(ia360):=20bandeja=20de=20ideas=20del?= =?UTF-8?q?=20owner=20=E2=80=94=20comando=20idea:,=20tarjeta=20de=20ruteo?= =?UTF-8?q?=20(Produccion/Documentar/CRM/Rechazar),=20endpoint=20interno?= =?UTF-8?q?=20de=20captura=20para=20Brain=20v2,=20cola=20ia360=5Fdocs=5Fsy?= =?UTF-8?q?nc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/webhook.js | 177 +++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 9fcb4fa..daa713a 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -1923,7 +1923,7 @@ async function callIa360Agent({ record, stageName }) { let agentMemory = null; try { const memContact = await loadIa360ContactContext(record); - agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 12 }); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); } catch (memErr) { console.error('[ia360-agent] memory lookup failed:', memErr.message); } @@ -3427,6 +3427,133 @@ async function sendIa360DirectText({ record, toNumber, body, label, targetContac } } +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + // POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent // tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { @@ -4410,6 +4537,11 @@ async function handleIa360LiteInteractive(record) { // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } if (ownerAction === 'owner_pipe') { await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); return; @@ -5883,6 +6015,18 @@ router.post('/webhook/whatsapp', async (req, res) => { if (incomingRecords.length > 0) { for (const record of incomingRecords) { try { + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } // ── CANARY Brain v2 (reversible, allowlist) ────────────────── // Antes de TODO el pipeline del monolito: si el remitente esta en la // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca @@ -6123,6 +6267,37 @@ router.post('/internal/ia360-revenue/opener', async (req, res) => { } }); +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + router.post('/internal/ia360-memory/capture', async (req, res) => { if (!isIa360InternalAuthorized(req)) { return res.status(401).json({ ok: false, error: 'unauthorized' }); From f4b56b23a819e38c977763e8efb4f36bd856cc3b Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 10 Jun 2026 15:28:50 +0000 Subject: [PATCH 19/39] =?UTF-8?q?checkpoint(ia360):=20estado=20vivo=20pre-?= =?UTF-8?q?consola=20=E2=80=94=20guardrail=20UX=20Quiero=20mapa=20entrega?= =?UTF-8?q?=20mapa=20real=20(sin=20offer=5Frouter)=20+=20botones=20Agendar?= =?UTF-8?q?/Llamada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/webhook.js | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index daa713a..aac868a 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -4814,9 +4814,9 @@ async function handleIa360LiteInteractive(record) { ], }, 'quiero mapa': { - stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', - body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', buttons: [ { id: '100m_urgent', title: 'Sí, urgente' }, { id: '100m_exploring', title: 'Estoy explorando' }, @@ -4933,25 +4933,9 @@ async function handleIa360LiteInteractive(record) { console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); } } - // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de - // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. - if (flow100m.tag === 'mapa-30-60-90-solicitado') { - try { - const flowSent = await enqueueIa360FlowMessage({ - record, - flowId: '2185399508915155', - screen: 'PERFIL_EMPRESA', - cta: 'Ver mi oferta', - bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', - mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', - flowToken: 'ia360_offer_router', - label: `ia360_100m_${flow100m.tag}`, - }); - if (flowSent) return; - } catch (flowErr) { - console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); - } - } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { @@ -5777,9 +5761,8 @@ async function handleIa360LiteInteractive(record) { footer: { text: 'Siguiente micro-paso' }, action: { buttons: [ - { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, - { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, ], }, }, From 58927c0668b9305043c08444fd6f960126d30cc4 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 10 Jun 2026 15:40:00 +0000 Subject: [PATCH 20/39] =?UTF-8?q?feat(ia360):=20comando=20del=20owner=20"q?= =?UTF-8?q?u=C3=A9=20sabes=20de=20"=20=E2=80=94=20expe?= =?UTF-8?q?diente=20read-only=20de=20facts+events=20(resoluci=C3=B3n=20por?= =?UTF-8?q?=20nombre=20tolerante=20a=20acentos/typos,=20candidatos=20si=20?= =?UTF-8?q?ambiguo,=20sin=20expediente=20nunca=20mudo;=20interceptor=20ant?= =?UTF-8?q?es=20del=20canary=20Brain=20v2,=20patr=C3=B3n=20idea:)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/webhook.js | 160 ++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index aac868a..2b11c11 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -3427,6 +3427,153 @@ async function sendIa360DirectText({ record, toNumber, body, label, targetContac } } +// ─── Expediente del owner: "qué sabes de " ────────────────── +// Comando read-only del owner: arma un expediente con los facts y eventos de +// coexistence.ia360_memory_* para un contacto, resuelto por número o por +// nombre (tolerante a acentos y a typos simples tipo Emmanuel/Emanuel). +// Egress SOLO vía sendIa360DirectText; nunca escribe memoria y SIEMPRE +// responde algo (sin expediente / candidatos / error), nunca queda mudo. +const IA360_BOT_WA_NUMBER = '5213321594582'; // número del bot: jamás es contacto + +function parseIa360OwnerMemoryQuery(body) { + const text = String(body || '').trim(); + const m = text.match(/^¿?\s*(?:qu[eé]|qui[eé]n)\s+sabes\s+(?:de\s+la|de\s+el|del|de|sobre)\s+(.+?)\s*\?*$/i); + if (!m) return null; + const q = m[1].trim(); + return q || null; +} + +// Normaliza para comparar nombres: minúsculas, sin acentos y con letras +// repetidas colapsadas ("Emmanuel" y "Emanuel" → "emanuel"). +function ia360NormalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +async function resolveIa360MemoryTarget(query) { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + // Número directo: 10 dígitos MX → prefijo 521 (formato ForgeChat). + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const { rows } = await pool.query( + `SELECT DISTINCT contact_number, contact_name FROM ( + SELECT contact_number, contact_name + FROM coexistence.ia360_memory_events + WHERE contact_name IS NOT NULL AND contact_number IS NOT NULL + UNION ALL + SELECT contact_number, COALESCE(name, profile_name) AS contact_name + FROM coexistence.contacts + WHERE COALESCE(name, profile_name) IS NOT NULL AND contact_number IS NOT NULL + ) t + WHERE contact_number <> $1`, + [IA360_BOT_WA_NUMBER] + ); + const needle = ia360NormalizeNameForMatch(query); + if (!needle) return { kind: 'none', candidates: [] }; + const byNumber = new Map(); + for (const r of rows) { + if (!ia360NormalizeNameForMatch(r.contact_name).includes(needle)) continue; + if (!byNumber.has(r.contact_number)) byNumber.set(r.contact_number, r); + } + const candidates = [...byNumber.values()]; + if (!candidates.length) return { kind: 'none', candidates: [] }; + if (candidates.length > 1) return { kind: 'ambiguous', candidates }; + return { kind: 'name', candidates }; +} + +async function buildIa360ContactDossier(contactNumber) { + const num = normalizePhone(contactNumber); + const { rows: factRows } = await pool.query( + `SELECT project_name, persona, role, account_name, preference, objection, + recurring_pain, affected_process, missing_metric + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + ORDER BY last_seen_at DESC, id DESC`, + [num] + ); + const { rows: eventRows } = await pool.query( + `SELECT contact_name, area, signal_type, summary + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC, id DESC + LIMIT 12`, + [num] + ); + if (!factRows.length && !eventRows.length) return null; + + const name = eventRows.find(e => e.contact_name)?.contact_name || null; + const header = [`Expediente IA360: ${name || 'contacto'} (${num})`]; + const meta = []; + const accountRow = factRows.find(f => f.account_name); + const projectRow = factRows.find(f => f.project_name); + const personaRow = factRows.find(f => f.persona); + if (accountRow) meta.push(`Cuenta: ${accountRow.account_name}`); + if (projectRow) meta.push(`Proyecto: ${projectRow.project_name}`); + if (personaRow) meta.push(`Persona: ${personaRow.persona}`); + if (meta.length) header.push(meta.join(' · ')); + + // Los facts viven duplicados por el doble keying de contact_wa_number + // (monolito vs lookup v2): dedupe por contenido, no por fila. + const factLines = []; + const seenFacts = new Set(); + for (const f of factRows) { + for (const field of ['preference', 'objection', 'recurring_pain', 'affected_process', 'missing_metric']) { + const val = String(f[field] || '').trim(); + if (!val) continue; + const key = `${field}:${val}`; + if (seenFacts.has(key)) continue; + seenFacts.add(key); + factLines.push(`- ${val.length > 300 ? `${val.slice(0, 297)}...` : val}`); + } + } + const eventLines = []; + const seenEvents = new Set(); + for (const e of eventRows) { + const val = String(e.summary || '').trim(); + if (!val) continue; + const key = `${e.area}|${e.signal_type}|${val}`; + if (seenEvents.has(key)) continue; + seenEvents.add(key); + eventLines.push(`- [${e.area}/${e.signal_type}] ${val.length > 220 ? `${val.slice(0, 217)}...` : val}`); + } + + const lines = [...header, '']; + if (factLines.length) lines.push(`Facts (${factLines.length}):`, ...factLines, ''); + if (eventLines.length) lines.push(`Eventos recientes (${eventLines.length}):`, ...eventLines); + let body = lines.join('\n').trim(); + // Límite duro de WhatsApp: 4096 chars por texto. + if (body.length > 3900) body = `${body.slice(0, 3880)}\n... (recortado)`; + return body; +} + +async function handleIa360OwnerMemoryQuery({ record, query }) { + let body; + try { + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + body = `Sin expediente: no encontré facts ni eventos para "${query}". Revisa el nombre o mándame el número completo.`; + } else if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + body = `Encontré varios contactos que coinciden con "${query}". ¿De cuál quieres el expediente?\n${list}\n\nMándame "qué sabes de " para verlo.`; + } else { + const dossier = await buildIa360ContactDossier(target.candidates[0].contact_number); + body = dossier + || `Sin expediente: el contacto ${target.candidates[0].contact_number} no tiene facts ni eventos guardados todavía.`; + } + } catch (err) { + console.error('[ia360-expediente] dossier error:', err.message); + body = `No pude leer el expediente de "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); +} + // ─── Bandeja de ideas del owner ───────────────────────────────────────────── // Una idea (comando del owner "idea: ", detección en conversación vía // Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una @@ -6010,6 +6157,19 @@ router.post('/webhook/whatsapp', async (req, res) => { continue; // no procesar como mensaje normal } } + // ── EXPEDIENTE: comando del owner "qué sabes de " ── + // Mismo patrón que "idea:": va ANTES del canary Brain v2 (el owner + // está en la allowlist y el canary haría continue). Read-only sobre + // ia360_memory_facts/events; responde SIEMPRE (nunca queda mudo). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const memQuery = parseIa360OwnerMemoryQuery(record.message_body); + if (memQuery) { + await handleIa360OwnerMemoryQuery({ record, query: memQuery }) + .catch(e => console.error('[ia360-expediente] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } // ── CANARY Brain v2 (reversible, allowlist) ────────────────── // Antes de TODO el pipeline del monolito: si el remitente esta en la // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca From b7f995fa8b97a2ec0f85b3291f9bd9ae9f627ef9 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 10 Jun 2026 17:56:17 +0000 Subject: [PATCH 21/39] feat(ia360): openers v2 persona-first con interactive (botones/lista) + cold-send 43/41 + fallback global + quien_intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Catálogo IA360_PERSONA_SEQUENCE_FLOWS: 24 copys v2 con contexto de relación, firma única "soy la IA de Alek" (cero Alek Zen / TransformIA), saludo con primer nombre (D9) y openerOptions (botones <=3 / lista 4+, ids seq_:). - buildIa360OpenerInteractive + approve-send dentro de ventana envía interactive (dedupSuffix :opener:); fuera de ventana ya no bloquea para aliado_mapa_colaboracion (template 43) ni referido_permiso_agenda (template 41). - Guardia requiresLiveDeal en cliente_expansion: solo dispara con deal open en P2/P7; bloqueo con aviso al owner (deny no_live_deal). - Fallback global de interactive al final del dispatcher: ningún button/list reply sin handler queda mudo (acuse al contacto + aviso al owner). - quien_intro en alta vCard cuando el que comparte no es el owner (D6). - Endpoint interno POST /internal/ia360-openers/preview (auth directiva): vista previa fiel del opener SOLO al owner. - E2E contra producción: previews 5 críticos delivered, T4/T5 cold-send sin outside_window_no_template, T6 guardia negativa (0 egress), T7 opener interactive dentro de ventana, fallback id inexistente + Sí-cuéntame, quien_intro sim vCard. Deuda anotada para G-C: try/catch guardia, sanitizar quien_intro, dedupe doble tap, ruteo de respuestas seq_*. Co-Authored-By: Claude Fable 5 --- backend/src/routes/webhook.js | 472 +++++++++++++++++++++++++++++++--- 1 file changed, 441 insertions(+), 31 deletions(-) diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 2b11c11..aa55e49 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -845,7 +845,24 @@ function inferIa360QaPersonaHint(name) { async function upsertIa360SharedContact({ record, shared }) { if (!record?.wa_number || !shared?.contactNumber) return null; const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + // quien_intro (D6): si el vCard lo comparte un CONTACTO (no el owner), esa + // persona es quien hizo la introducción. Se guarda el NOMBRE para que el + // opener referido_contexto pueda decir "nos presentó X". Si lo manda el owner, + // el dato queda pendiente (el placeholder {{quien_intro}} bloquea el copy). + let quienIntro = null; + if (record.contact_number && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + try { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(record.contact_number)] + ); + quienIntro = String(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || '').trim() || null; + } catch (e) { + console.error('[ia360-vcard] quien_intro lookup:', e.message); + } + } const customFields = { + ...(quienIntro ? { quien_intro: quienIntro } : {}), staged: true, stage: 'Capturado / Por rutear', captured_at: new Date().toISOString(), @@ -2074,7 +2091,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', cta: 'pedir permiso para una pregunta corta de validación', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_architectura:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_architectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_architectura:ahora_no', title: 'Ahora no' }, + ], + }, }, { id: 'beta_feedback', @@ -2084,7 +2109,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'comentario técnico accionable sobre una parte del sistema', nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', cta: 'pedir una crítica concreta del flujo', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_feedback:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_feedback:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_feedback:ahora_no', title: 'Ahora no' }, + ], + }, }, { id: 'beta_memoria', @@ -2094,7 +2127,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'validación de si el contexto guardado ayuda o estorba', nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', cta: 'probar memoria con una pregunta controlada', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_memoria:si_a_ver', title: 'Sí, a ver' }, + { id: 'seq_beta_memoria:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_memoria:ahora_no', title: 'Ahora no' }, + ], + }, }, ], }, @@ -2113,7 +2154,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', cta: 'pedir contexto breve de la introducción', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_contexto:pregunta', title: 'Hazme una pregunta' }, + { id: 'seq_referido_contexto:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_contexto:ahora_no', title: 'Ahora no' }, + ], + }, }, { id: 'referido_oneliner', @@ -2123,7 +2172,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'interés inicial sin romper la confianza del canal', nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', cta: 'pedir permiso para explicar IA360 en una línea', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_oneliner:si_cuentame', title: 'Sí, cuéntame más' }, + { id: 'seq_referido_oneliner:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_oneliner:ahora_no', title: 'Por ahora no' }, + ], + }, }, { id: 'referido_permiso_agenda', @@ -2133,7 +2190,16 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', nextAction: 'Alek confirma que la introducción justifica proponer agenda.', cta: 'pedir permiso para sugerir una llamada', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, + metaTemplateName: 'ia360_referido_apertura', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_permiso_agenda:horarios', title: 'Proponme horarios' }, + { id: 'seq_referido_permiso_agenda:pregunta', title: 'Primero una pregunta' }, + { id: 'seq_referido_permiso_agenda:ahora_no', title: 'Ahora no' }, + ], + }, }, ], }, @@ -2152,7 +2218,16 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', cta: 'mapear fit de colaboración', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, + metaTemplateName: 'ia360_aliado_mapa_colaboracion', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_mapa_colaboracion:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_mapa_colaboracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_mapa_colaboracion:ahora_no', title: 'Ahora no' }, + ], + }, }, { id: 'aliado_criterios_fit', @@ -2162,7 +2237,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'criterios de cliente ideal o señales para descartar', nextAction: 'Alek valida criterios de fit antes de pedir intros.', cta: 'pedir señales de cliente compatible', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_criterios_fit:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_criterios_fit:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_criterios_fit:ahora_no', title: 'Ahora no' }, + ], + }, }, { id: 'aliado_caso_reventa', @@ -2172,7 +2255,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'interés en caso seguro para presentar o revender', nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', cta: 'ofrecer caso seguro y resumido', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_caso_reventa:si_comparte', title: 'Sí, compártelo' }, + { id: 'seq_aliado_caso_reventa:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_caso_reventa:ahora_no', title: 'Ahora no' }, + ], + }, }, ], }, @@ -2191,7 +2282,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', cta: 'pedir validación del avance', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_readout:si_cuento', title: 'Sí, te cuento' }, + { id: 'seq_cliente_readout:todo_bien', title: 'Todo va bien' }, + { id: 'seq_cliente_readout:alek_directo', title: 'Que me escriba Alek' }, + ], + }, }, { id: 'cliente_soporte', @@ -2201,7 +2300,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', cta: 'detectar fricción concreta', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_soporte:hay_tema', title: 'Sí, hay un tema' }, + { id: 'seq_cliente_soporte:todo_orden', title: 'Todo en orden' }, + { id: 'seq_cliente_soporte:alek_directo', title: 'Que me escriba Alek' }, + ], + }, }, { id: 'cliente_expansion', @@ -2211,7 +2318,20 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', cta: 'identificar siguiente módulo con permiso', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te escribo de su parte porque tú y Alek ya tienen un proyecto andando, y Alek quiere ubicar dónde estaría el siguiente paso con más impacto, sin empujarte nada fuera de tiempo. De estas áreas, ¿cuál te quita más tiempo hoy?`, + requiresLiveDeal: true, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cliente_expansion:whatsapp', title: 'WhatsApp y mensajes' }, + { id: 'seq_cliente_expansion:crm', title: 'CRM y clientes' }, + { id: 'seq_cliente_expansion:datos', title: 'Datos y reportes' }, + { id: 'seq_cliente_expansion:agenda', title: 'Agenda y citas' }, + { id: 'seq_cliente_expansion:seguimiento', title: 'Seguimiento de ventas' }, + { id: 'seq_cliente_expansion:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, ], }, @@ -2230,7 +2350,18 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', cta: 'detectar cuello ejecutivo', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte antes de mandarte una demo genérica: prefiere ubicar primero dónde habría valor real para tu operación. De estas áreas, ¿dónde sientes el cuello de botella que más mueve la aguja?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_sponsor_diagnostico:operacion', title: 'Operación' }, + { id: 'seq_sponsor_diagnostico:ventas', title: 'Ventas' }, + { id: 'seq_sponsor_diagnostico:datos', title: 'Datos y reportes' }, + { id: 'seq_sponsor_diagnostico:seguimiento', title: 'Seguimiento' }, + { id: 'seq_sponsor_diagnostico:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, { id: 'sponsor_fuga_valor', @@ -2240,7 +2371,18 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', cta: 'pedir síntoma de fuga de valor', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir fuga', + options: [ + { id: 'seq_sponsor_fuga_valor:tiempo', title: 'Tiempo perdido' }, + { id: 'seq_sponsor_fuga_valor:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_sponsor_fuga_valor:datos', title: 'Datos poco confiables' }, + { id: 'seq_sponsor_fuga_valor:decisiones', title: 'Decisiones lentas' }, + { id: 'seq_sponsor_fuga_valor:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, { id: 'sponsor_caso_ndasafe', @@ -2250,7 +2392,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', nextAction: 'Alek elige el caso más parecido antes de compartirlo.', cta: 'ofrecer prueba segura', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_sponsor_caso_ndasafe:si_manda', title: 'Sí, mándalo' }, + { id: 'seq_sponsor_caso_ndasafe:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_sponsor_caso_ndasafe:ahora_no', title: 'Ahora no' }, + ], + }, }, ], }, @@ -2269,7 +2419,17 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', cta: 'ubicar fuga principal del pipeline', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja ayudando a equipos comerciales y casi siempre el problema aparece en uno de tres lugares. En tu equipo, ¿cuál duele más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_pipeline:leads', title: 'Leads que no llegan' }, + { id: 'seq_comercial_pipeline:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_comercial_pipeline:contexto', title: 'WhatsApp sin contexto' }, + { id: 'seq_comercial_pipeline:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, { id: 'comercial_wa_crm', @@ -2279,7 +2439,18 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', cta: 'mapear seguimiento WhatsApp/CRM', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_wa_crm:historial', title: 'Historial de clientes' }, + { id: 'seq_comercial_wa_crm:seguimiento', title: 'Seguimiento' }, + { id: 'seq_comercial_wa_crm:prioridad', title: 'Prioridad de leads' }, + { id: 'seq_comercial_wa_crm:datos', title: 'Datos para decidir' }, + { id: 'seq_comercial_wa_crm:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, { id: 'comercial_motor_prospeccion', @@ -2289,7 +2460,17 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', cta: 'detectar si hay motor comercial repetible', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_motor_prospeccion:segmento', title: 'Segmento claro' }, + { id: 'seq_comercial_motor_prospeccion:mensaje', title: 'Mensaje repetible' }, + { id: 'seq_comercial_motor_prospeccion:seguimiento', title: 'Seguimiento medible' }, + { id: 'seq_comercial_motor_prospeccion:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, ], }, @@ -2308,7 +2489,18 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', cta: 'detectar punto de control débil', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja con equipos de finanzas que terminan operando a mano porque no pueden confiar rápido en sus datos. En tu caso, ¿dónde está el mayor dolor hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cfo_control:cartera', title: 'Cartera' }, + { id: 'seq_cfo_control:comisiones', title: 'Comisiones' }, + { id: 'seq_cfo_control:reportes', title: 'Reportes' }, + { id: 'seq_cfo_control:conciliacion', title: 'Conciliación' }, + { id: 'seq_cfo_control:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, { id: 'cfo_cartera_datos', @@ -2318,7 +2510,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', cta: 'ubicar datos financieros poco visibles', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cfo_cartera_datos:respondo', title: 'Te respondo aquí' }, + { id: 'seq_cfo_cartera_datos:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_cfo_cartera_datos:ahora_no', title: 'Ahora no' }, + ], + }, }, { id: 'cfo_comisiones', @@ -2329,6 +2529,16 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', cta: 'detectar reglas financieras propensas a error', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_cfo_comisiones:reglas', title: 'Reglas manuales' }, + { id: 'seq_cfo_comisiones:excepciones', title: 'Excepciones' }, + { id: 'seq_cfo_comisiones:datos', title: 'Datos que no cuadran' }, + { id: 'seq_cfo_comisiones:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, ], }, @@ -2347,7 +2557,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', cta: 'pedir revisión de mapa de integración', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_arquitectura:mapa', title: 'Mándame el mapa' }, + { id: 'seq_tecnico_arquitectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_arquitectura:ahora_no', title: 'Ahora no' }, + ], + }, }, { id: 'tecnico_rollback', @@ -2357,7 +2575,19 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', cta: 'identificar riesgo técnico principal', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada?`, + openerOptions: { + kind: 'list', + button: 'Elegir riesgo', + options: [ + { id: 'seq_tecnico_rollback:permisos', title: 'Permisos' }, + { id: 'seq_tecnico_rollback:datos', title: 'Datos' }, + { id: 'seq_tecnico_rollback:trazabilidad', title: 'Trazabilidad' }, + { id: 'seq_tecnico_rollback:reversibilidad', title: 'Reversibilidad' }, + { id: 'seq_tecnico_rollback:dependencia', title: 'Dependencia operativa' }, + { id: 'seq_tecnico_rollback:alek_directo', title: 'Hablar con Alek' }, + ], + }, }, { id: 'tecnico_integracion', @@ -2367,7 +2597,15 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'condiciones para una prueba limitada y auditable', nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', cta: 'definir prueba técnica controlada', - draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_integracion:respondo', title: 'Te respondo aquí' }, + { id: 'seq_tecnico_integracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_integracion:ahora_no', title: 'Ahora no' }, + ], + }, }, ], }, @@ -2426,10 +2664,49 @@ function hasUnresolvedIa360Placeholder(text) { return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); } +// Openers v2: saludo con primer nombre (D9). Limpia el sufijo de QA y toma el +// primer token; si no hay nada usable devuelve el valor original. +function ia360FirstNameFrom(name) { + const raw = String(name || '').trim().replace(/\s+WhatsApp IA360$/i, '').trim(); + return raw.split(/\s+/).filter(Boolean)[0] || raw; +} + +// Openers v2: arma el objeto `interactive` de un opener desde sequence.openerOptions +// (kind 'buttons' ≤3 opciones, kind 'list' 4+). Sin header ni footer: el copy +// aprobado por Alek va fiel en body.text. Devuelve null si la secuencia no tiene +// openerOptions (esas siguen saliendo como texto plano). +function buildIa360OpenerInteractive({ sequence, bodyText }) { + const opts = sequence && sequence.openerOptions; + if (!opts || !Array.isArray(opts.options) || !opts.options.length) return null; + if (opts.kind === 'list') { + return { + type: 'list', + body: { text: bodyText }, + action: { + button: opts.button || 'Elegir', + sections: [{ + title: 'Opciones', + rows: opts.options.map(o => ({ id: o.id, title: o.title, ...(o.description ? { description: o.description } : {}) })), + }], + }, + }; + } + return { + type: 'button', + body: { text: bodyText }, + action: { + buttons: opts.options.slice(0, 3).map(o => ({ type: 'reply', reply: { id: o.id, title: o.title } })), + }, + }; +} + function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { const customFields = contact?.custom_fields || {}; const name = contact?.name || targetContact; - const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + // Openers v2: saludo con primer nombre (D9) + quién hizo la introducción (D6). + const draftName = ia360FirstNameFrom(name); + const quienIntro = String(customFields.quien_intro || '').trim() || null; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name: draftName, quienIntro }) : String(sequence.draft || ''); const relationshipContext = flow.relationshipContext || ''; const isCapturedOnly = relationshipContext === 'solo_guardar'; const isDoNotContact = relationshipContext === 'no_contactar'; @@ -2534,6 +2811,9 @@ function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payloa '', 'Borrador propuesto:', payload.sequence_candidate.draft, + ...(sequence.openerOptions && Array.isArray(sequence.openerOptions.options) + ? ['', `Opciones del mensaje (${sequence.openerOptions.kind === 'list' ? 'lista' : 'botones'}): ${sequence.openerOptions.options.map(o => o.title).join(' | ')}`] + : []), '', `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, @@ -2877,6 +3157,26 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); } + // GUARDIA cliente_expansion (D7): la secuencia presupone un proyecto andando. + // Solo dispara si el contacto tiene un deal vivo (status='open') en P2 (IA360 + // WhatsApp Revenue Pipeline) o P7 (Champions). Sin deal vivo → bloquear con aviso. + if (sequence.requiresLiveDeal) { + const { rows: liveRows } = await pool.query( + `SELECT 1 + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') + AND d.contact_wa_number = $1 + AND d.contact_number = $2 + AND d.status = 'open' + LIMIT 1`, + [record.wa_number, targetContact] + ); + if (!liveRows.length) { + return deny('no_live_deal', `${name} no tiene un proyecto activo (deal vivo en P2/P7). La secuencia ${sequence.id} solo aplica a clientes con proyecto en curso; elige otra secuencia. No envié nada.`); + } + } + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. // '*' = la aprobación explícita del owner autoriza a cualquier contacto. const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); @@ -2896,14 +3196,31 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } let sendResult = { ok: false, status: 'not_sent', error: null }; const openerLabel = `ia360_seq_opener_${sequence.id}`; if (insideWindow) { - const sent = await sendIa360DirectText({ - record, - toNumber: targetContact, - label: openerLabel, - body: pf.sequence_candidate.draft, - }); + // Openers v2: dentro de ventana el opener sale como interactive (botones/lista) + // con el copy aprobado en el readout; secuencias sin openerOptions siguen en texto. + const openerInteractive = buildIa360OpenerInteractive({ sequence, bodyText: pf.sequence_candidate.draft }); + let sent; + let handlerFor; + if (openerInteractive) { + sent = await enqueueIa360Interactive({ + record: targetRecord, + label: openerLabel, + messageBody: `IA360 opener: ${sequence.label}`, + interactive: openerInteractive, + dedupSuffix: `:opener:${targetContact}`, + }); + handlerFor = `${record.message_id}:opener:${targetContact}`; + } else { + sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + handlerFor = `${record.message_id}:direct:${targetContact}`; + } if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); - const status = await waitForIa360OutboundStatus(`${record.message_id}:direct:${targetContact}`); + const status = await waitForIa360OutboundStatus(handlerFor); sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; } else if (sequence.metaTemplateName) { const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); @@ -5916,6 +6233,41 @@ async function handleIa360LiteInteractive(record) { }); return; } + + // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── + // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo, + // id de opener v2 sin ruteo todavía, o quick reply de template sin estado, + // p. ej. "Sí, cuéntame" de ia360_referido_apertura). Antes el contacto quedaba + // MUDO; ahora siempre recibe acuse y el owner se entera. try/catch terminal: + // nunca tumba el webhook. + try { + const fallbackId = replyId || answer || '(sin id)'; + console.warn('[ia360-fallback] unhandled interactive reply contact=%s id=%s body=%s', record.contact_number || '-', fallbackId, String(record.message_body || '').slice(0, 80)); + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_ack', + body: `Recibí tu respuesta "${String(record.message_body || fallbackId).slice(0, 60)}", pero aún no tengo una acción conectada para ese botón (${fallbackId}). No hice ningún cambio.`, + }); + return; + } + await enqueueIa360Text({ + record, + label: 'ia360_interactive_fallback', + body: 'Recibí tu respuesta y la estoy ubicando para darte una respuesta útil. Si es urgente, Alek también puede escribirte directo.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_notice', + body: `Alek, ${record.contact_name || record.contact_number} (${record.contact_number}) respondió "${String(record.message_body || fallbackId).slice(0, 60)}" (id: ${fallbackId}) y no tengo un manejador para esa opción. Le acusé recibo; revisa si quieres tomarlo tú.`, + targetContact: record.contact_number, + ownerBudget: true, + }); + } catch (fbErr) { + console.error('[ia360-fallback] interactive fallback error:', fbErr.message); + } } @@ -6410,6 +6762,64 @@ router.post('/internal/ia360-revenue/opener', async (req, res) => { } }); +// OPENERS V2 — vista previa de un opener al WhatsApp del OWNER (nunca a un +// contacto). Renderiza el draft v2 (primer nombre + quien_intro opcional) y el +// interactive (botones/lista) tal como lo vería el contacto. Único egress: +// sendOwnerInteractive / sendIa360DirectText -> messageSender. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). +router.post('/internal/ia360-openers/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const sequenceId = String(b.sequence_id || '').trim().toLowerCase(); + const found = findIa360SequenceFlow(sequenceId); + if (!found) return res.status(422).json({ ok: false, error: 'unknown_sequence', sequence_id: sequenceId }); + const { sequence } = found; + const sampleName = ia360FirstNameFrom(String(b.name || 'Alek').trim() || 'Alek'); + const quienIntro = String(b.quien_intro || '').trim() || null; + const bodyText = typeof sequence.draft === 'function' ? sequence.draft({ name: sampleName, quienIntro }) : String(sequence.draft || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const synthetic = { + wa_number: waNumber, + contact_number: IA360_OWNER_NUMBER, + message_id: `opener-preview-${sequenceId}-${Date.now()}`, + message_type: 'text', + direction: 'incoming', + }; + const interactive = buildIa360OpenerInteractive({ sequence, bodyText }); + let sent; + if (interactive) { + // ownerBudget=false: la preview es una petición explícita del owner; no debe + // caer en el presupuesto anti-spam de notificaciones. + sent = await sendOwnerInteractive({ + record: synthetic, + label: `ia360_opener_preview_${sequenceId}`, + messageBody: `IA360 preview opener ${sequenceId}`, + interactive, + }); + } else { + sent = await sendIa360DirectText({ + record: synthetic, + toNumber: IA360_OWNER_NUMBER, + label: `ia360_opener_preview_${sequenceId}`, + body: bodyText, + }); + } + return res.status(sent ? 200 : 502).json({ + ok: Boolean(sent), + schema: 'ia360_opener_preview.v1', + sequence_id: sequenceId, + kind: interactive ? (interactive.type === 'list' ? 'list' : 'buttons') : 'text', + body_preview: bodyText, + }); + } catch (err) { + console.error('[ia360-openers] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + // BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros // agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: // sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. From f94fe26f96e8a04463310c9444b37afa2dab392c Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 10 Jun 2026 18:54:22 +0000 Subject: [PATCH 22/39] =?UTF-8?q?feat(ia360):=20G-C=20=E2=80=94=20ruteo=20?= =?UTF-8?q?real=20seq=5F*,=20anti-loop=20100M,=20CTAs=20=C3=BAnicos=20y=20?= =?UTF-8?q?deudas=20G-B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Router handleIa360SequenceReply: cada botón/fila seq_* de los 24 openers v2 registra la respuesta (custom_fields + deal) y responde paso 2 del catálogo (step2), manejo semántico (alek_directo/ahora_no/horarios) o acuse específico con aviso al owner (nextAction). Ningún seq_* válido cae al fallback genérico. - Anti-loop 100M: guard de estado (agenda/reunión/handoff no se reabre, con reuniones FUTURAS vía loadIa360BookingsForList), mapa ia360_100m_visited con nodo condensado para visitas repetidas, No prioritario sin botón Aplicarlo. - CTAs únicos: alias de quick replies de template (Sí, cuéntame / Ahora no) resuelto por estado del contacto (pf.send) hacia ids seq_* únicos; Revenue OS sigue gateado por ia360_revenue_state; templateAliasOption en referidos. - Deudas G-B: try/catch fail-closed en guardia requiresLiveDeal, sanitización de quien_intro (control chars, invisibles Unicode, surrogates, no-pisar, sin auto-introducción), dedupe de doble tap en owner_approve_send (permite reintento tras envío fallido). - Guard de estado también en el router seq_* (botón viejo no regresa deals) y limpieza de ia360_seq_last_response al reenviar opener (review adversarial). - gc-e2e.sh: suite E2E de sims firmados HMAC (31 checks) contra producción. E2E: 31/31 PASS + T6 (seq stale -> continuidad) + T7/T7b (dedupe tras envío exitoso / reintento tras fallo). Backup: webhook.js.bak-pre-gc-20260610T182557Z Co-Authored-By: Claude Fable 5 --- backend/src/routes/webhook.js | 508 ++++++++++++++++++++++++++++++++-- gc-e2e.sh | 153 ++++++++++ 2 files changed, 642 insertions(+), 19 deletions(-) create mode 100644 gc-e2e.sh diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index aa55e49..16d76fa 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -842,6 +842,24 @@ function inferIa360QaPersonaHint(name) { return null; } +// G-C: el nombre del introductor viene de push name / vCard (texto controlado por +// el remitente). Se sanitiza antes de persistir: sin caracteres de control ni +// saltos de línea, sin llaves de placeholder, espacios colapsados y tope de 60 +// caracteres. Devuelve null si no queda nada usable. +function sanitizeIa360IntroName(raw) { + const clean = String(raw || '') + .replace(/[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ' ') + .replace(/[{}]/g, '') + .replace(/\s+/g, ' ') + .trim(); + // Corte por code points (no por unidades UTF-16): un emoji en la frontera de + // los 60 caracteres no deja un surrogate suelto que rompa el jsonb al persistir. + const capped = Array.from(clean).slice(0, 60).join('').trim(); + if (!capped) return null; + if (!/[\p{L}]/u.test(capped)) return null; // sin letras (solo dígitos/símbolos) no sirve como nombre + return capped; +} + async function upsertIa360SharedContact({ record, shared }) { if (!record?.wa_number || !shared?.contactNumber) return null; const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); @@ -849,16 +867,27 @@ async function upsertIa360SharedContact({ record, shared }) { // persona es quien hizo la introducción. Se guarda el NOMBRE para que el // opener referido_contexto pueda decir "nos presentó X". Si lo manda el owner, // el dato queda pendiente (el placeholder {{quien_intro}} bloquea el copy). + // G-C: sanitizado (push name inyectable), sin auto-introducción (vCard propio) + // y sin pisar un quien_intro ya capturado. let quienIntro = null; - if (record.contact_number && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + const sharerIsSelf = normalizePhone(record.contact_number || '') === normalizePhone(shared.contactNumber || ''); + if (record.contact_number && !sharerIsSelf && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { try { const { rows: introRows } = await pool.query( `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, [record.wa_number, normalizePhone(record.contact_number)] ); - quienIntro = String(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || '').trim() || null; + quienIntro = sanitizeIa360IntroName(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || ''); + if (quienIntro) { + const { rows: existingRows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(shared.contactNumber)] + ); + if (String(existingRows[0]?.quien_intro || '').trim()) quienIntro = null; // ya hay introductor registrado: no pisar + } } catch (e) { console.error('[ia360-vcard] quien_intro lookup:', e.message); + quienIntro = null; } } const customFields = { @@ -2091,6 +2120,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', cta: 'pedir permiso para una pregunta corta de validación', + step2: { + si_pregunta: 'Va la pregunta: si este mensaje te hubiera llegado sin conocer a Alek, ¿se entiende qué es IA360 y qué puedo y no puedo hacer como IA, o hay algo que te haría desconfiar? Dímelo con toda franqueza; para eso es esta prueba.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, openerOptions: { kind: 'buttons', @@ -2109,6 +2141,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'comentario técnico accionable sobre una parte del sistema', nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', cta: 'pedir una crítica concreta del flujo', + step2: { + si_pregunta: 'Gracias. ¿Cómo se siente recibir un mensaje así de una IA: natural, raro o invasivo? Lo que me digas se lo paso a Alek tal cual, sin suavizarlo.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, openerOptions: { kind: 'buttons', @@ -2127,6 +2162,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'validación de si el contexto guardado ayuda o estorba', nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', cta: 'probar memoria con una pregunta controlada', + step2: { + si_a_ver: 'Va. Pregúntame algo que Alek y tú hayan platicado o trabajado antes, y te digo qué tengo registrado. Tú pones la prueba.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, openerOptions: { kind: 'buttons', @@ -2154,6 +2192,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', cta: 'pedir contexto breve de la introducción', + step2: { + pregunta: '¿Qué te contó la persona que nos presentó sobre lo que hace Alek, y qué te llamó la atención para aceptar la introducción? Con eso evitamos mandarte algo fuera de lugar.', + }, draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, openerOptions: { kind: 'buttons', @@ -2172,6 +2213,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'interés inicial sin romper la confianza del canal', nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', cta: 'pedir permiso para explicar IA360 en una línea', + step2: { + si_cuentame: 'Va la versión completa en corto: IA360 conecta WhatsApp, CRM, agenda y memoria de clientes para que el seguimiento no dependa de la memoria de nadie. ¿En tu operación dónde se cae más el seguimiento hoy: mensajes, CRM o agenda?', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, openerOptions: { kind: 'buttons', @@ -2190,8 +2234,14 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', nextAction: 'Alek confirma que la introducción justifica proponer agenda.', cta: 'pedir permiso para sugerir una llamada', + step2: { + pregunta: 'Claro, pregunta con confianza: qué hace IA360, cómo trabaja Alek o qué implicaría la llamada. Te respondo aquí mismo.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, metaTemplateName: 'ia360_referido_apertura', + // El template de Meta trae botones de texto ("Sí, cuéntame"); su afirmativo + // mapea a la rama de horarios (el copy pide permiso para proponer llamada). + templateAliasOption: 'horarios', openerOptions: { kind: 'buttons', options: [ @@ -2218,6 +2268,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', cta: 'mapear fit de colaboración', + step2: { + si_pregunta: 'Gracias. ¿Qué tipo de clientes atiendes hoy y dónde los ves sufrir más: WhatsApp desordenado, CRM sin seguimiento o procesos repetidos a mano? Con eso mapeamos el fit.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, metaTemplateName: 'ia360_aliado_mapa_colaboracion', openerOptions: { @@ -2237,6 +2290,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'criterios de cliente ideal o señales para descartar', nextAction: 'Alek valida criterios de fit antes de pedir intros.', cta: 'pedir señales de cliente compatible', + step2: { + si_pregunta: 'Va: cuando un cliente tuyo ya necesita ordenar WhatsApp, CRM o seguimiento, ¿qué señales lo delatan primero? Con eso definimos juntos a quién sí presentarle IA360.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, openerOptions: { kind: 'buttons', @@ -2255,6 +2311,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'interés en caso seguro para presentar o revender', nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', cta: 'ofrecer caso seguro y resumido', + step2: { + si_comparte: 'Va el caso NDA-safe en corto: una empresa de servicios perdía seguimiento entre WhatsApp y su CRM; con IA360 cada conversación queda registrada, el pipeline se mueve solo y el dueño revisa su semana en un tablero. ¿Le haría sentido a alguno de tus clientes?', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, openerOptions: { kind: 'buttons', @@ -2282,6 +2341,10 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', cta: 'pedir validación del avance', + step2: { + si_cuento: 'Te leo. Cuéntame el avance, la fricción o el pendiente con el detalle que quieras; se lo dejo a Alek con contexto hoy mismo.', + todo_bien: 'Qué bueno. Le paso a Alek que todo va en orden. Cualquier cosa que surja, me escribes por aquí y se lo pongo enfrente.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, openerOptions: { kind: 'buttons', @@ -2300,6 +2363,10 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', cta: 'detectar fricción concreta', + step2: { + hay_tema: 'Cuéntame el tema con el detalle que quieras; se lo paso a Alek hoy mismo con prioridad para que no se quede atorado.', + todo_orden: 'Perfecto, me da gusto. Le confirmo a Alek que no hay pendientes de su lado. Aquí sigo si surge algo.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, openerOptions: { kind: 'buttons', @@ -2392,6 +2459,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', nextAction: 'Alek elige el caso más parecido antes de compartirlo.', cta: 'ofrecer prueba segura', + step2: { + si_manda: 'Va el caso en corto: una operación que dependía de WhatsApp y Excel perdía seguimiento y visibilidad; con IA360 los mensajes alimentan el CRM, el pipeline se mueve solo y la dirección revisa su semana en un tablero. Si quieres, Alek te aterriza el paralelo con tu operación en una llamada corta.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, openerOptions: { kind: 'buttons', @@ -2510,6 +2580,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', cta: 'ubicar datos financieros poco visibles', + step2: { + respondo: 'Te leo. Cuéntame qué información te cuesta más tener confiable y a tiempo (cartera, cobranza, reportes), y se la paso a Alek aterrizada.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, openerOptions: { kind: 'buttons', @@ -2557,6 +2630,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', cta: 'pedir revisión de mapa de integración', + step2: { + mapa: 'Va el mapa corto: WhatsApp Cloud API → ForgeChat (bandeja y reglas) → n8n (orquestación) → CRM y memoria por contacto. Todo con permisos mínimos, trazabilidad de cada mensaje y aprobación humana antes de cualquier envío sensible. Si quieres el detalle técnico completo, Alek te lo manda directo.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, openerOptions: { kind: 'buttons', @@ -2597,6 +2673,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { expectedSignal: 'condiciones para una prueba limitada y auditable', nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', cta: 'definir prueba técnica controlada', + step2: { + respondo: 'Te leo. Dime qué condición tendría que cumplirse para que la prueba te parezca segura (permisos, alcance, datos, reversibilidad) y la registro tal cual para Alek.', + }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, openerOptions: { kind: 'buttons', @@ -2700,6 +2779,299 @@ function buildIa360OpenerInteractive({ sequence, bodyText }) { }; } +// ── G-C: ruteo real de respuestas seq_* (openers v2) ───────────────────────── +// Un botón/fila `seq_:` del catálogo persona-first SIEMPRE +// recibe un siguiente paso real: paso 2 definido en el catálogo (`step2`), +// manejo semántico compartido (alek_directo / ahora_no / horarios) o acuse +// específico con eco de la elección + aviso al owner con la nextAction de la +// secuencia. Devuelve true si lo manejó; false SOLO para ids seq_* que no están +// en el catálogo (esos sí caen al fallback global, porque son inválidos). +async function handleIa360SequenceReply({ record, replyId, contact = null }) { + const m = /^seq_([a-z0-9_]+):([a-z0-9_]+)$/.exec(String(replyId || '').trim().toLowerCase()); + if (!m) return false; + const sequenceId = m[1]; + const optionKey = m[2]; + const found = findIa360SequenceFlow(sequenceId); + if (!found) return false; + const { sequence } = found; + const option = (sequence.openerOptions?.options || []) + .find(o => String(o.id).toLowerCase() === `seq_${sequenceId}:${optionKey}`); + if (!option) return false; + try { + const ctx = contact || await loadIa360ContactContext(record).catch(() => null); + const cf = ctx?.custom_fields || {}; + const contactName = ctx?.name || record.contact_name || record.contact_number; + const safeName = sanitizeIa360IntroName(contactName) || record.contact_number; + const nowIso = new Date().toISOString(); + + // Guard de estado (paridad con el router 100M): si la conversación ya avanzó + // a agenda/reunión/handoff humano, un botón seq_* de un opener viejo NO mueve + // el deal hacia atrás; responde continuidad y el owner se entera del tap. + const guard = await ia360HundredMAdvancedGuard(record); + if (guard.advanced) { + await enqueueIa360Text({ record, label: `ia360_seq_continuity_${sequenceId}`, body: guard.body }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_stale_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) tocó "${option.title}" de un opener viejo ("${sequence.label}"), pero su proceso ya va más adelante. No moví nada; le respondí con continuidad.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] stale notify:', e.message)); + return true; + } + + // Dedupe de doble tap del contacto: misma secuencia+opción ya registrada → + // continuidad corta, sin re-registro ni avisos duplicados al owner. + const prev = cf.ia360_seq_last_response || null; + if (prev && prev.sequence === sequenceId && prev.option === optionKey) { + await enqueueIa360Text({ + record, + label: `ia360_seq_dup_${sequenceId}`, + body: `Ya tengo registrada tu respuesta "${option.title}" y Alek ya tiene el contexto. Quedo al pendiente; cualquier cosa me escribes por aquí.`, + }); + return true; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-seq-respuesta', `seq-${sequenceId}`], + customFields: { + ia360_seq_last_response: { sequence: sequenceId, option: optionKey, title: option.title, at: nowIso }, + ia360_ultima_respuesta: option.title, + ultimo_cta_enviado: `ia360_seq_reply_${sequenceId}_${optionKey}`, + }, + }).catch(e => console.error('[ia360-seq] merge state:', e.message)); + + const notifyOwner = (detalle) => sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_reply_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) respondió "${option.title}" al opener "${sequence.label}". ${detalle}`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] notify owner:', e.message)); + + // 1) Salida directa con Alek. + if (optionKey === 'alek_directo') { + await enqueueIa360Text({ + record, + label: `ia360_seq_alek_directo_${sequenceId}`, + body: 'Perfecto, le aviso a Alek ahora mismo para que te escriba directo. Gracias por responder.', + }); + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió hablar directo con Alek.`, + }).catch(e => console.error('[ia360-seq] deal alek_directo:', e.message)); + await notifyOwner('Pidió que le escribas TÚ directo. Deal en "Requiere Alek".'); + return true; + } + + // 2) Cierre suave → nutrición. + if (optionKey === 'ahora_no') { + await enqueueIa360Text({ + record, + label: `ia360_seq_ahora_no_${sequenceId}`, + body: 'De acuerdo, no te insisto. Si más adelante quieres retomarlo, me escribes por aquí y seguimos donde lo dejamos.', + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: {}, + }).catch(e => console.error('[ia360-seq] tag nutricion:', e.message)); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: ahora no. Pasa a nutrición suave.`, + }).catch(e => console.error('[ia360-seq] deal ahora_no:', e.message)); + await notifyOwner('Respondió que ahora no; queda en nutrición suave.'); + return true; + } + + // 3) Agenda con permiso (referido_permiso_agenda:horarios). + if (optionKey === 'horarios') { + await enqueueIa360Interactive({ + record, + label: `ia360_seq_horarios_${sequenceId}`, + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + body: { text: 'Perfecto. ¿Qué ventana te acomoda mejor para la llamada con Alek?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió horarios. Deal a "Agenda en proceso".`, + }).catch(e => console.error('[ia360-seq] deal horarios:', e.message)); + await notifyOwner('Pidió horarios para una llamada contigo. Deal en "Agenda en proceso".'); + return true; + } + + // 4) Paso 2 definido en el catálogo. + const step2 = sequence.step2 && sequence.step2[optionKey]; + if (step2) { + await enqueueIa360Text({ + record, + label: `ia360_seq_step2_${sequenceId}_${optionKey}`, + body: step2, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Paso 2 de la secuencia enviado.`, + }).catch(e => console.error('[ia360-seq] deal step2:', e.message)); + await notifyOwner(`Le envié el paso 2 de la secuencia. Next action sugerida: ${sequence.nextAction}`); + return true; + } + + // 5) Sin paso 2 en el catálogo (temas de lista): acuse específico con eco de + // la elección + aviso al owner con la respuesta y la next action sugerida. + await enqueueIa360Text({ + record, + label: `ia360_seq_ack_${sequenceId}_${optionKey}`, + body: `Gracias, registré tu respuesta: "${option.title}". Le paso este contexto a Alek para que el siguiente paso vaya directo a eso, sin rodeos. Te escribe él con una propuesta concreta.`, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Acuse enviado; siguiente paso con Alek.`, + }).catch(e => console.error('[ia360-seq] deal ack:', e.message)); + await notifyOwner(`Next action sugerida: ${sequence.nextAction}`); + return true; + } catch (err) { + console.error('[ia360-seq] reply error:', err.message); + // Nunca mudo: acuse mínimo aunque el registro haya fallado. + await enqueueIa360Text({ + record, + label: 'ia360_seq_ack_error', + body: 'Recibí tu respuesta y ya se la pasé a Alek. Te escribe él en corto.', + }).catch(() => {}); + return true; + } +} + +// ── G-C: CTAs únicos — alias de botones de template (quick replies de texto) ── +// Los templates fríos (p. ej. ia360_referido_apertura = template 41, +// ia360_aliado_mapa_colaboracion = template 43) llegan con button.payload = +// TEXTO del botón, no un id estructurado, por lo que "Sí, cuéntame" era ambiguo +// entre Revenue OS y Referidos. Revenue OS se resuelve ANTES en el dispatch +// (handleRevenueOsButton, gateado por ia360_revenue_state); si no era suyo, este +// alias traduce el texto al id seq_* ÚNICO de la secuencia persona-first cuyo +// opener realmente se le envió al contacto (pf.sequence_candidate.id + pf.send). +const IA360_SEQ_ALIAS_NEGATIVE = new Set(['ahora no', 'por ahora no', 'no por ahora']); +const IA360_SEQ_ALIAS_HANDOFF = new Set(['que me escriba alek', 'hablar con alek']); +// Solo frases genuinamente afirmativas. Los títulos exactos del catálogo +// ("Proponme horarios", "Te respondo aquí", etc.) se resuelven por match de +// título, no por semántica: ponerlos aquí fabricaría elecciones equivocadas. +const IA360_SEQ_ALIAS_AFFIRMATIVE = new Set([ + 'sí, cuéntame', 'si, cuéntame', 'sí, cuentame', 'si, cuentame', + 'sí, cuéntame más', 'si, cuentame mas', + 'sí, pregúntame', 'si, preguntame', + 'sí, mándalo', 'si, mandalo', + 'sí, compártelo', 'si, compartelo', + 'sí, a ver', 'si, a ver', + 'sí, te cuento', 'si, te cuento', + 'sí, hay un tema', 'si, hay un tema', + 'me interesa', 'sí, me interesa', 'si, me interesa', +]); + +function resolveIa360TemplateButtonAlias({ replyId, contact }) { + const key = String(replyId || '').trim().toLowerCase(); + if (!key || key.startsWith('seq_')) return null; + const isNeg = IA360_SEQ_ALIAS_NEGATIVE.has(key); + const isHand = IA360_SEQ_ALIAS_HANDOFF.has(key); + const isAff = IA360_SEQ_ALIAS_AFFIRMATIVE.has(key); + if (!isNeg && !isHand && !isAff) return null; + const pf = contact?.custom_fields?.ia360_persona_first; + const seqId = pf?.sequence_candidate?.id; + if (!seqId || !pf?.send?.sent_at) return null; // solo si su opener realmente salió + const found = findIa360SequenceFlow(seqId); + if (!found) return null; + const opts = found.sequence.openerOptions?.options || []; + // 1) Match exacto por título visible del botón. + const byTitle = opts.find(o => String(o.title).trim().toLowerCase() === key); + if (byTitle) return String(byTitle.id).toLowerCase(); + // 2) Por semántica: negativo → ahora_no; handoff → alek_directo; afirmativo → + // la primera opción que no sea ninguna de las dos (el camino afirmativo). + const bySuffix = (suffix) => opts.find(o => String(o.id).toLowerCase().endsWith(`:${suffix}`)); + if (isNeg) { const o = bySuffix('ahora_no'); return o ? String(o.id).toLowerCase() : null; } + if (isHand) { const o = bySuffix('alek_directo'); return o ? String(o.id).toLowerCase() : null; } + // Afirmativo SOLO cuando es inequívoco: la secuencia declara su opción de + // template (templateAliasOption) o existe exactamente UNA opción no terminal. + // Con varias opciones posibles (listas de temas) NO se fabrica una elección: + // se devuelve null y el fallback global acusa recibo y avisa al owner. + if (found.sequence.templateAliasOption) { + const o = bySuffix(found.sequence.templateAliasOption); + if (o) return String(o.id).toLowerCase(); + } + const nonTerminal = opts.filter(o => { + const id = String(o.id).toLowerCase(); + return !id.endsWith(':ahora_no') && !id.endsWith(':alek_directo'); + }); + return nonTerminal.length === 1 ? String(nonTerminal[0].id).toLowerCase() : null; +} + +// ── G-C: anti-loop del router 100M ─────────────────────────────────────────── +// Nodos que en las pruebas reales generaron ciclos (doc 2026-06-10, chat_history +// 1068-1079 y 1135-1142): exploración, mecanismos, mapa y ejemplo. Una visita +// repetida ya no reenvía el bloque completo: responde una versión condensada con +// salidas terminales (agendar / llamada / más adelante). +const IA360_100M_LOOP_PRONE = new Set([ + 'explorando', + 'mecanismo-whatsapp-crm', + 'mecanismo-erp-bi', + 'mecanismo-agentic-followup', + 'mapa-30-60-90-solicitado', + 'ejemplo-solicitado', +]); +// Etapas donde la conversación ya avanzó a agenda/handoff humano: un botón 100M +// de un mensaje viejo NO debe reabrir la rama (guard de estado/versión). +const IA360_100M_ADVANCED_STAGES = new Set(['Agenda en proceso', 'Reunión agendada', 'Requiere Alek']); + +async function ia360HundredMAdvancedGuard(record) { + const out = { advanced: false, body: '', visited: {}, visitedOk: false }; + try { + const contact = await loadIa360ContactContext(record).catch(() => null); + const cf = contact?.custom_fields || {}; + if (contact) { + out.visited = (cf.ia360_100m_visited && typeof cf.ia360_100m_visited === 'object') ? cf.ia360_100m_visited : {}; + out.visitedOk = true; // lectura confiable: se puede escribir sin pisar el mapa + } + // Solo reuniones FUTURAS cuentan como "en curso": el cache crudo ia360_bookings + // conserva citas pasadas y atraparía al contacto para siempre. + const bookings = await loadIa360BookingsForList(record.contact_number).catch(() => []); + const hasBooking = Array.isArray(bookings) && bookings.length > 0; + let stageName = ''; + const deal = await getActiveNonTerminalIa360Deal(record).catch(() => null); + if (deal) stageName = deal.stage_name || ''; + if (hasBooking || IA360_100M_ADVANCED_STAGES.has(stageName)) { + out.advanced = true; + out.body = (hasBooking || stageName === 'Reunión agendada') + ? 'Vi tu respuesta, pero tu proceso ya va más adelante: tienes una reunión en curso con Alek. Sigo con eso para no regresarte al inicio. Si quieres mover la reunión o retomar otro tema, dímelo por aquí.' + : 'Vi tu respuesta a un mensaje anterior, pero tu proceso ya va más adelante: estamos en la parte de agenda con Alek. Sigo con eso para no darte vueltas; si quieres retomar otro tema, dímelo por aquí y lo vemos.'; + } + } catch (err) { + console.error('[ia360-100m] advanced guard:', err.message); + } + return out; +} + function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { const customFields = contact?.custom_fields || {}; const name = contact?.name || targetContact; @@ -3157,21 +3529,44 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); } + // G-C: dedupe de doble tap. Si esta misma secuencia ya fue aprobada y su envío + // ya salió SIN fallar, un segundo tap de la tarjeta NO debe generar otro egress. + // Un envío fallido NO bloquea: el owner puede reintentar con la misma tarjeta. + if (pf.approval?.status === 'approved' && pf.send?.sent_at && String(pf.send.send_status || '').toLowerCase() !== 'failed' && !pf.send.error) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_dup', + body: `Ese opener ("${sequence.label}") ya se había enviado a ${name} (${pf.send.sent_at}). Detecté un doble tap y no envié nada nuevo.`, + targetContact, + ownerBudget: true, + }); + return; + } + // GUARDIA cliente_expansion (D7): la secuencia presupone un proyecto andando. // Solo dispara si el contacto tiene un deal vivo (status='open') en P2 (IA360 // WhatsApp Revenue Pipeline) o P7 (Champions). Sin deal vivo → bloquear con aviso. + // G-C: con try/catch — si la consulta falla, el owner se entera (nunca mudo) y + // NO se envía nada (fail-closed). if (sequence.requiresLiveDeal) { - const { rows: liveRows } = await pool.query( - `SELECT 1 - FROM coexistence.deals d - JOIN coexistence.pipelines p ON p.id = d.pipeline_id - WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') - AND d.contact_wa_number = $1 - AND d.contact_number = $2 - AND d.status = 'open' - LIMIT 1`, - [record.wa_number, targetContact] - ); + let liveRows; + try { + ({ rows: liveRows } = await pool.query( + `SELECT 1 + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') + AND d.contact_wa_number = $1 + AND d.contact_number = $2 + AND d.status = 'open' + LIMIT 1`, + [record.wa_number, targetContact] + )); + } catch (liveErr) { + console.error('[ia360-approve] live deal check failed:', liveErr.message); + return deny('live_deal_check_failed', `No pude verificar si ${name} tiene un proyecto activo (error de base de datos). Por seguridad no envié nada; reintenta en un momento.`); + } if (!liveRows.length) { return deny('no_live_deal', `${name} no tiene un proyecto activo (deal vivo en P2/P7). La secuencia ${sequence.id} solo aplica a clientes con proyecto en curso; elige otra secuencia. No envié nada.`); } @@ -3255,6 +3650,10 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } sent_at: nowIso, send_status: sendResult.status, outbound_message_id: sendResult.message_id || null, + // G-C: un opener nuevo abre un ciclo nuevo — la respuesta del ciclo anterior + // no debe activar el dedupe del router seq_* (el contacto debe poder volver + // a elegir la misma opción y recibir su paso 2). + ia360_seq_last_response: null, }, }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); @@ -5174,6 +5573,25 @@ async function handleIa360LiteInteractive(record) { // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. if (await handleRevenueOsButton({ record, replyId })) return; + // ── G-C: ruteo de respuestas a openers v2 (ids seq_* y alias de template) ── + // Va DESPUÉS de Revenue OS (que resuelve su propio "Sí, cuéntame" gateado por + // estado) y ANTES del embudo 100M. Un id seq_* del catálogo NUNCA cae al + // fallback global. + if (replyId && replyId.startsWith('seq_')) { + if (await handleIa360SequenceReply({ record, replyId })) return; + } else if (replyId || answer) { + const aliasKey = String(replyId || answer || '').trim().toLowerCase(); + if (IA360_SEQ_ALIAS_NEGATIVE.has(aliasKey) || IA360_SEQ_ALIAS_HANDOFF.has(aliasKey) || IA360_SEQ_ALIAS_AFFIRMATIVE.has(aliasKey)) { + try { + const aliasContact = await loadIa360ContactContext(record).catch(() => null); + const aliased = resolveIa360TemplateButtonAlias({ replyId: aliasKey, contact: aliasContact }); + if (aliased && await handleIa360SequenceReply({ record, replyId: aliased, contact: aliasContact })) return; + } catch (aliasErr) { + console.error('[ia360-seq] alias error:', aliasErr.message); + } + } + } + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). @@ -5316,12 +5734,13 @@ async function handleIa360LiteInteractive(record) { { id: '100m_schedule', title: 'Agendar' }, ], }, + // G-C anti-loop: "No prioritario" ya NO ofrece "Aplicarlo" (reabría la rama + // comercial); las salidas son nutrición ("Más adelante") o baja. 'no prioritario': { stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', buttons: [ { id: '100m_more_later', title: 'Más adelante' }, - { id: '100m_apply_later', title: 'Aplicarlo' }, { id: '100m_optout', title: 'Baja' }, ], }, @@ -5360,6 +5779,57 @@ async function handleIa360LiteInteractive(record) { }; const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; if (flow100m) { + // ── G-C: anti-loop del router 100M ────────────────────────────────────── + // 'baja' (optout) SIEMPRE pasa: la salida del contacto no se bloquea nunca. + if (flow100m.tag !== 'no-contactar') { + try { + const guard = await ia360HundredMAdvancedGuard(record); + // Guard de estado/versión: la conversación ya avanzó a agenda/reunión/ + // handoff humano → un botón de un mensaje viejo NO reabre la rama. + if (guard.advanced) { + await enqueueIa360Text({ record, label: 'ia360_100m_continuity', body: guard.body }); + return; + } + // Nodo loop-prone repetido → versión condensada con salidas terminales, + // no el bloque completo otra vez. Si la lectura del contacto falló, NO se + // escribe el mapa de visitas (se pisaría con un objeto vacío). + const visited = guard.visited || {}; + if (guard.visitedOk && IA360_100M_LOOP_PRONE.has(flow100m.tag)) { + if (visited[flow100m.tag]) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: (Number(visited[flow100m.tag]) || 0) + 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + await enqueueIa360Interactive({ + record, + label: 'ia360_100m_condensed', + messageBody: `IA360 100M: ${flow100m.title} (resumen)`, + interactive: { + type: 'button', + body: { text: `Eso ya lo vimos: ${flow100m.title}. Para no darte vueltas con lo mismo, mejor dime cómo cerramos: ¿agendamos una llamada corta con Alek o lo dejamos para más adelante?` }, + footer: { text: 'IA360 · sin vueltas' }, + action: { + buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada con Alek' } }, + { type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }, + ], + }, + }, + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + } + } catch (guardErr) { + console.error('[ia360-100m] guard error:', guardErr.message); + } + } await mergeContactIa360State({ waNumber: record.wa_number, contactNumber: record.contact_number, @@ -6235,11 +6705,11 @@ async function handleIa360LiteInteractive(record) { } // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── - // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo, - // id de opener v2 sin ruteo todavía, o quick reply de template sin estado, - // p. ej. "Sí, cuéntame" de ia360_referido_apertura). Antes el contacto quedaba - // MUDO; ahora siempre recibe acuse y el owner se entera. try/catch terminal: - // nunca tumba el webhook. + // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo o + // malformado). Los ids seq_* del catálogo y los quick replies de template con + // estado persona-first ya se rutean arriba (handleIa360SequenceReply + alias); + // aquí solo cae lo verdaderamente desconocido. El contacto siempre recibe + // acuse y el owner se entera. try/catch terminal: nunca tumba el webhook. try { const fallbackId = replyId || answer || '(sin id)'; console.warn('[ia360-fallback] unhandled interactive reply contact=%s id=%s body=%s', record.contact_number || '-', fallbackId, String(record.message_body || '').slice(0, 80)); diff --git a/gc-e2e.sh b/gc-e2e.sh new file mode 100644 index 0000000..f17e08b --- /dev/null +++ b/gc-e2e.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E G-C — ruteo seq_*, anti-loop 100M, CTAs unicos, dedupe doble tap. +# Sims contra produccion con numeros QA (5219990000801-806). Sin contactos reales. +# Uso: bash gc-e2e.sh +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="873315362541590" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" + +QA_BETA="5219990000806" # persona beta (botones) — la crea approve-send-e2e.sh +QA_REFERIDO="5219990000802" # persona referido (botones) +QA_CLIENTE="5219990000803" # persona cliente (lista) +QA_ALIADO="5219990000801" # alias CTA "Si, cuentame" (pf.send presente) +QA_LOOP="5219990000805" # anti-loop 100M + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (obtuvo='$2' esperado~'$3')"; FAIL=$((FAIL+1)); } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_not(){ if echo "$2" | grep -qF "$3"; then bad "$1" "contiene $3" "sin $3"; else ok "$1"; fi; } +chk_eq(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});await r.text();process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } +WB="wamid.e2e.gc.$(ts)" + +inject_button(){ # $1=from $2=reply_id $3=title + local body="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"e2e\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID_NUM\"},\"contacts\":[{\"profile\":{\"name\":\"QA\"},\"wa_id\":\"$1\"}],\"messages\":[{\"from\":\"$1\",\"id\":\"$WB.$RANDOM\",\"timestamp\":\"$(ts)\",\"type\":\"interactive\",\"interactive\":{\"type\":\"button_reply\",\"button_reply\":{\"id\":\"$2\",\"title\":\"$3\"}}}]},\"field\":\"messages\"}]}]}" + post_webhook "$body" +} +inject_list(){ # $1=from $2=row_id $3=title + local body="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"e2e\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID_NUM\"},\"contacts\":[{\"profile\":{\"name\":\"QA\"},\"wa_id\":\"$1\"}],\"messages\":[{\"from\":\"$1\",\"id\":\"$WB.$RANDOM\",\"timestamp\":\"$(ts)\",\"type\":\"interactive\",\"interactive\":{\"type\":\"list_reply\",\"list_reply\":{\"id\":\"$2\",\"title\":\"$3\"}}}]},\"field\":\"messages\"}]}]}" + post_webhook "$body" +} +inject_tpl_button(){ # $1=from $2=payload_texto (quick reply de template) + local body="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"e2e\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID_NUM\"},\"contacts\":[{\"profile\":{\"name\":\"QA\"},\"wa_id\":\"$1\"}],\"messages\":[{\"from\":\"$1\",\"id\":\"$WB.$RANDOM\",\"timestamp\":\"$(ts)\",\"type\":\"button\",\"button\":{\"payload\":\"$2\",\"text\":\"$2\"}}]},\"field\":\"messages\"}]}]}" + post_webhook "$body" +} + +last_out(){ # $1=contact — ultimo saliente + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' ORDER BY id DESC LIMIT 1" +} +last_out_label(){ # $1=contact $2=label + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' AND template_meta->>'label'='$2' ORDER BY id DESC LIMIT 1" +} +stage_of(){ # $1=contact + psql_q "SELECT s.name FROM coexistence.deals d JOIN coexistence.pipeline_stages s ON s.id=d.stage_id JOIN coexistence.pipelines p ON p.id=d.pipeline_id WHERE p.name='IA360 WhatsApp Revenue Pipeline' AND d.contact_number='$1' ORDER BY d.updated_at DESC NULLS LAST, d.id DESC LIMIT 1" +} +seq_resp(){ # $1=contact + psql_q "SELECT custom_fields->'ia360_seq_last_response' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$1'" +} + +echo "==============================================================" +echo "TEST 1 — seq_* BOTONES persona beta ($QA_BETA): si_pregunta -> paso 2" +echo "==============================================================" +ST=$(inject_button "$QA_BETA" "seq_beta_architectura:si_pregunta" "Sí, pregúntame"); chk_eq "HTTP" "$ST" "200" +sleep 7 +R=$(last_out_label "$QA_BETA" "ia360_seq_step2_beta_architectura_si_pregunta") +chk_has "paso 2 de la secuencia enviado" "$R" "Va la pregunta" +SR=$(seq_resp "$QA_BETA"); chk_has "respuesta registrada en custom_fields" "$SR" "si_pregunta" +chk_not "no cayo al fallback generico" "$(last_out "$QA_BETA")" "la estoy ubicando" + +echo "==============================================================" +echo "TEST 2 — seq_* BOTONES persona referido ($QA_REFERIDO): horarios -> agenda" +echo "==============================================================" +ST=$(inject_button "$QA_REFERIDO" "seq_referido_permiso_agenda:horarios" "Proponme horarios"); chk_eq "HTTP" "$ST" "200" +sleep 7 +R=$(last_out_label "$QA_REFERIDO" "ia360_seq_horarios_referido_permiso_agenda") +chk_has "pregunta de ventana de agenda enviada" "$R" "ventana te acomoda" +chk_eq "deal en Agenda en proceso" "$(stage_of "$QA_REFERIDO")" "Agenda en proceso" +SR=$(seq_resp "$QA_REFERIDO"); chk_has "respuesta registrada" "$SR" "horarios" + +echo "==============================================================" +echo "TEST 3 — seq_* LISTA persona cliente ($QA_CLIENTE): datos -> acuse especifico" +echo "==============================================================" +ST=$(inject_list "$QA_CLIENTE" "seq_cliente_expansion:datos" "Datos y reportes"); chk_eq "HTTP" "$ST" "200" +sleep 7 +R=$(last_out_label "$QA_CLIENTE" "ia360_seq_ack_cliente_expansion_datos") +chk_has "acuse especifico con eco del tema" "$R" "Datos y reportes" +SR=$(seq_resp "$QA_CLIENTE"); chk_has "respuesta registrada" "$SR" "cliente_expansion" +OWN=$(psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='owner_seq_reply_cliente_expansion' ORDER BY id DESC LIMIT 1") +chk_has "owner notificado con next action" "$OWN" "Next action" + +echo "==============================================================" +echo "TEST 4 — alias CTA template ($QA_ALIADO): 'Sí, cuéntame' -> id seq_* unico" +echo "==============================================================" +ST=$(inject_tpl_button "$QA_ALIADO" "Sí, cuéntame"); chk_eq "HTTP" "$ST" "200" +sleep 7 +R=$(last_out_label "$QA_ALIADO" "ia360_seq_step2_aliado_mapa_colaboracion_si_pregunta") +chk_has "alias ruteo a paso 2 de aliado (NO Revenue OS, NO fallback)" "$R" "clientes atiendes" +chk_not "no cayo al fallback generico" "$(last_out "$QA_ALIADO")" "la estoy ubicando" +REV=$(psql_q "SELECT coalesce(custom_fields->>'ia360_revenue_state','') FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$QA_ALIADO'") +chk_eq "no abrio flujo Revenue OS" "$REV" "" + +echo "==============================================================" +echo "TEST 5 — anti-loop 100M ($QA_LOOP)" +echo "==============================================================" +echo "--- 5a: primer 'Estoy explorando' -> bloque completo" +ST=$(inject_button "$QA_LOOP" "100m_exploring" "Estoy explorando"); chk_eq "HTTP" "$ST" "200" +sleep 7 +R=$(last_out_label "$QA_LOOP" "ia360_100m_explorando") +chk_has "primera visita: bloque exploracion completo" "$R" "modo exploración" +echo "--- 5b: segundo 'Estoy explorando' -> version condensada" +ST=$(inject_button "$QA_LOOP" "100m_exploring" "Estoy explorando"); chk_eq "HTTP" "$ST" "200" +sleep 7 +R=$(last_out_label "$QA_LOOP" "ia360_100m_condensed") +chk_has "visita repetida: condensado con salidas terminales" "$R" "Eso ya lo vimos" +echo "--- 5c: 'WhatsApp -> CRM' primera y repetida" +ST=$(inject_button "$QA_LOOP" "100m_wa_crm" "WhatsApp → CRM"); chk_eq "HTTP" "$ST" "200" +sleep 7 +R1=$(last_out_label "$QA_LOOP" "ia360_100m_mecanismo-whatsapp-crm") +chk_has "primera visita mecanismo completo" "$R1" "clasifica" +ST=$(inject_button "$QA_LOOP" "100m_wa_crm" "WhatsApp → CRM"); chk_eq "HTTP" "$ST" "200" +sleep 7 +N_COND=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA_LOOP' AND template_meta->>'label'='ia360_100m_condensed'") +chk_eq "mecanismo repetido tambien condensado (2 condensados)" "$N_COND" "2" +echo "--- 5d: 'No prioritario' sin boton Aplicarlo" +ST=$(inject_button "$QA_LOOP" "100m_not_priority" "No prioritario"); chk_eq "HTTP" "$ST" "200" +sleep 7 +RAWNP=$(psql_q "SELECT coalesce(template_meta::text,'')||' '||coalesce(message_body,'') FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA_LOOP' ORDER BY id DESC LIMIT 1") +chk_not "respuesta de No prioritario sin 'Aplicarlo'" "$RAWNP" "Aplicarlo" +echo "--- 5e: avanzar a agenda y probar guard de estado" +ST=$(inject_button "$QA_LOOP" "100m_schedule" "Agendar"); chk_eq "HTTP" "$ST" "200" +sleep 7 +chk_eq "deal avanzo a Agenda en proceso" "$(stage_of "$QA_LOOP")" "Agenda en proceso" +ST=$(inject_button "$QA_LOOP" "100m_want_map" "Quiero mapa"); chk_eq "HTTP" "$ST" "200" +sleep 7 +R=$(last_out_label "$QA_LOOP" "ia360_100m_continuity") +chk_has "boton viejo con estado avanzado -> continuidad (no reabre)" "$R" "ya va más adelante" +chk_eq "deal NO retrocedio" "$(stage_of "$QA_LOOP")" "Agenda en proceso" + +echo "" +echo "=== RESULTADO G-C: PASS=$PASS FAIL=$FAIL ===" +[ "$FAIL" = "0" ] && exit 0 || exit 1 From 9b6b537696bb91e2d3682d6dfd4ae541193881f2 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 10 Jun 2026 19:43:38 +0000 Subject: [PATCH 23/39] =?UTF-8?q?feat(ia360):=20G-D=20=E2=80=94=20selector?= =?UTF-8?q?=20de=20secuencias=20RECOMENDADO=20(ranker=20rule-based=20sin?= =?UTF-8?q?=20LLM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La tarjeta "Elegir secuencia" del flujo vCard deja de ser estática: un ranker rule-based consulta señales reales de la base (deal vivo + pipeline/etapa, quien_intro/referido_por, ia360_memory_facts/events, última interacción) y ordena las secuencias de la persona con la sugerida primero y el porqué en su descripción. El cuerpo trae un resumen de 2 líneas del contacto (deal/fase + último evento o fact). Honestidad estricta: sin señales → orden default del catálogo, cero razones inventadas y línea única honesta; referido_por se ignora si es owner/bot/self y solo se cita con nombre presentable. Fail-open por consulta (try/catch individual): error de DB → selector default, nunca mudo. Ranking auditable en custom_fields.ia360_selector_ranking y message_body enriquecido con la sugerida. - gatherIa360ContactSignals: 5 consultas con tope LIMIT 1 e índices existentes; memoria keyed solo por contact_number (doble keying documentado). - rankIa360Sequences: función pura, solo reordena dentro de la persona elegida; ids owner_seq:* y label owner_sequence_selector_* intactos. - buildIa360ContactSummaryLines: líneas acotadas (180/120) para que el body jamás exceda los 1024 de Meta. - E2E gd-e2e.sh 26/26 PASS (3 perfiles QA: deal P7+fact sugiere expansión citando el deal; quien_intro sugiere referido_contexto citando al introductor; sin datos → default sin razones) + regresión approve-send-e2e.sh 15/15 PASS. - approve-send-e2e.sh: fix del harness — phone_number_id real 873315362541590 (el falso 123456789 rompía la ventana 24h). Co-Authored-By: Claude Fable 5 --- approve-send-e2e.sh | 2 +- backend/src/routes/webhook.js | 220 ++++++++++++++++++++++++++++++++-- gd-e2e.sh | 110 +++++++++++++++++ 3 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 gd-e2e.sh diff --git a/approve-send-e2e.sh b/approve-send-e2e.sh index 334fe23..9dfdd8a 100644 --- a/approve-send-e2e.sh +++ b/approve-send-e2e.sh @@ -13,7 +13,7 @@ MODE="${2:-negativo}" WA="5213321594582" OWNER="5213322638033" -PID_NUM="123456789" +PID_NUM="873315362541590" DB="forgecrm-db" BE="forgecrm-backend" ENVF="/home/alek/stack/forgechat-poc/backend/.env" diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 16d76fa..fc11792 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -3268,6 +3268,173 @@ async function persistIa360PersonaPayload({ record, targetContact, flow, sequenc }); } +// G-D: pipelines donde un deal vivo habilita la jugada de expansión (D7). +const IA360_EXPANSION_PIPELINES = ['IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión']; + +// G-D: señales reales del contacto para el ranker del selector de secuencias. +// Cada consulta tiene su propio try/catch (fail-open): si la DB falla, esa señal +// queda en null y el selector sale con el orden default — nunca mudo. +// OJO: ia360_memory_* tiene doble keying en contact_wa_number (a veces la línea +// del bot, a veces el número del contacto); la llave confiable es contact_number. +async function gatherIa360ContactSignals({ waNumber, contactNumber }) { + const signals = { liveDeal: null, quienIntro: null, lastFact: null, lastEvent: null, lastIncomingAt: null }; + try { + const { rows } = await pool.query( + `SELECT d.title, p.name AS pipeline_name, s.name AS stage_name + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.contact_wa_number = $1 AND d.contact_number = $2 AND d.status = 'open' + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [waNumber, contactNumber] + ); + if (rows.length) signals.liveDeal = { title: rows[0].title, pipelineName: rows[0].pipeline_name, stageName: rows[0].stage_name }; + } catch (e) { console.error('[ia360-rank] deal lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro, custom_fields->>'referido_por' AS referido_por + FROM coexistence.contacts + WHERE wa_number = $1 AND contact_number = $2 + LIMIT 1`, + [waNumber, contactNumber] + ); + const quienIntro = String(rows[0]?.quien_intro || '').trim(); + if (quienIntro) { + signals.quienIntro = quienIntro; + } else { + // referido_por guarda el NÚMERO de quien compartió el vCard. Solo cuenta + // como introductor si NO es el owner, ni el bot, ni el propio contacto; + // y solo con un nombre presentable (no citamos números pelones). + const referidoPor = normalizePhone(String(rows[0]?.referido_por || '').trim()); + if (referidoPor && referidoPor !== IA360_OWNER_NUMBER && referidoPor !== normalizePhone(waNumber) && referidoPor !== normalizePhone(contactNumber)) { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number = $1 AND contact_number = $2 LIMIT 1`, + [waNumber, referidoPor] + ); + const introName = String(introRows[0]?.name || introRows[0]?.profile_name || '').trim(); + if (introName) signals.quienIntro = introName; + } + } + } catch (e) { console.error('[ia360-rank] quien_intro lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT COALESCE(recurring_pain, preference, objection, role) AS texto, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + AND COALESCE(recurring_pain, preference, objection, role) IS NOT NULL + ORDER BY last_seen_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastFact = { text: rows[0].texto, lastSeenAt: rows[0].last_seen_at }; + } catch (e) { console.error('[ia360-rank] facts lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT summary, created_at + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastEvent = { summary: rows[0].summary, createdAt: rows[0].created_at }; + } catch (e) { console.error('[ia360-rank] events lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT MAX(created_at) AS last_in + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 AND direction = 'incoming'`, + [waNumber, contactNumber] + ); + if (rows[0]?.last_in) signals.lastIncomingAt = rows[0].last_in; + } catch (e) { console.error('[ia360-rank] chat_history lookup:', e.message); } + return signals; +} + +// G-D: ranker rule-based (SIN LLM). Solo REORDENA las secuencias de la persona +// elegida; cada razón cita una señal que existe en la base. Sin señales que +// matcheen secuencias de esta persona → orden de catálogo y cero razones +// inventadas (honestidad del ranker). +function rankIa360Sequences({ flow, signals }) { + const scores = new Map(); + const reasons = new Map(); + const bump = (id, pts, reason) => { + scores.set(id, (scores.get(id) || 0) + pts); + if (reason && !reasons.has(id)) reasons.set(id, reason); + }; + const s = signals || {}; + if (s.liveDeal) { + const dealReason = `Deal vivo «${s.liveDeal.title}» en ${s.liveDeal.pipelineName}`; + if (IA360_EXPANSION_PIPELINES.includes(s.liveDeal.pipelineName)) { + bump('cliente_expansion', 35, dealReason); + bump('cliente_readout', 20, dealReason); + bump('cliente_soporte', 10, dealReason); + } else { + bump('cliente_readout', 30, dealReason); + bump('cliente_soporte', 20, dealReason); + } + } + if (s.quienIntro) { + const introReason = `Te lo presentó ${s.quienIntro}`; + bump('referido_contexto', 30, introReason); + bump('referido_permiso_agenda', 15, introReason); + bump('referido_oneliner', 10, introReason); + } + const memorySignal = s.lastEvent || s.lastFact; + if (memorySignal) { + // 40 y no más: "Sugerida: Memoria registrada: " + frag debe caber en los + // 72 chars de la description de Meta sin perder el final de la razón. + const frag = compactForWhatsApp(s.lastEvent ? s.lastEvent.summary : s.lastFact.text, 40); + const memReason = `Memoria registrada: ${frag}`; + bump('beta_memoria', 15, memReason); + bump('cliente_readout', 10, memReason); + } + const ordered = (flow.sequences || []) + .map((seq, idx) => ({ seq, idx, score: scores.get(seq.id) || 0 })) + .sort((a, b) => (b.score - a.score) || (a.idx - b.idx)); + const ranked = ordered.length > 0 && ordered[0].score > 0; + return { + ordered: ordered.map(o => o.seq), + suggestedId: ranked ? ordered[0].seq.id : null, + reasonFor: (id) => reasons.get(id) || null, + ranked, + }; +} + +// G-D: resumen de 2 líneas del contacto para el cuerpo de la tarjeta. Solo +// afirma lo que existe; sin señales devuelve una sola línea honesta. +function buildIa360ContactSummaryLines(signals) { + const s = signals || {}; + const fmtDate = (d) => { + try { return new Date(d).toISOString().slice(0, 10); } catch { return ''; } + }; + if (!s.liveDeal && !s.quienIntro && !s.lastFact && !s.lastEvent) { + return ['Aún no tengo señales registradas de este contacto (sin deal, sin memoria, sin introductor).']; + } + // Tope de 180: título/pipeline/etapa vienen de la base sin límite y el body + // del interactive de Meta admite 1024 como máximo — si se excede, la tarjeta + // se vuelve muda. Acotado aquí, el body completo queda siempre < 1024. + const line1 = s.liveDeal + ? compactForWhatsApp(`Deal vivo: «${s.liveDeal.title}» — ${s.liveDeal.pipelineName}${s.liveDeal.stageName ? ` / ${s.liveDeal.stageName}` : ''}.`, 180) + : 'Sin deal vivo registrado.'; + let line2; + if (s.lastEvent) { + const fecha = fmtDate(s.lastEvent.createdAt); + line2 = `Último evento${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastEvent.summary, 120)}`; + } else if (s.lastFact) { + const fecha = fmtDate(s.lastFact.lastSeenAt); + line2 = `Memoria${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastFact.text, 120)}`; + } else if (s.quienIntro) { + line2 = `Lo presentó: ${s.quienIntro}.`; + } else if (s.lastIncomingAt) { + line2 = `Última interacción: ${fmtDate(s.lastIncomingAt)}.`; + } else { + line2 = 'Sin memoria registrada todavía.'; + } + return [line1, line2]; +} + async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { const name = contact?.name || targetContact; const payload = buildIa360PersonaPayload({ @@ -3295,28 +3462,63 @@ async function sendIa360SequenceSelector({ record, targetContact, contact, flowK payload, tags: [`persona-choice:${flowKey}`], }); + // G-D: ranker rule-based sobre señales reales — la sugerida primero con el + // porqué en su descripción; sin señales → orden de catálogo sin razones. + const signals = await gatherIa360ContactSignals({ waNumber: record.wa_number, contactNumber: targetContact }); + const ranking = rankIa360Sequences({ flow, signals }); + const summaryLines = buildIa360ContactSummaryLines(signals); + const bodyText = [ + `Alek, ${name} quedó como ${flow.personaContext}.`, + ...summaryLines, + 'Elige una secuencia. Sigo en dry-run: no enviaré nada al contacto.', + ].join('\n'); + const suggestedReason = ranking.suggestedId ? ranking.reasonFor(ranking.suggestedId) : null; + // G-D: el ranking queda auditable en custom_fields (orden, sugerida, razón, + // resumen) — best-effort, no bloquea el envío de la tarjeta. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + customFields: { + ia360_selector_ranking: { + at: new Date().toISOString(), + persona: flowKey, + ranked: ranking.ranked, + suggested: ranking.suggestedId, + reason: suggestedReason, + order: ranking.ordered.map(seq => seq.id), + summary: summaryLines, + }, + }, + }).catch(e => console.error('[ia360-rank] persist ranking:', e.message)); return sendOwnerInteractive({ record, label: `owner_sequence_selector_${targetContact}_${flowKey}`, - messageBody: `IA360: secuencias ${name}`, + messageBody: ranking.ranked + ? `IA360: secuencias ${name} — sugerida: ${ranking.suggestedId} (${suggestedReason})` + : `IA360: secuencias ${name}`, targetContact, ownerBudget: true, interactive: { type: 'list', header: { type: 'text', text: 'Elegir secuencia' }, - body: { - text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + body: { text: bodyText }, + footer: { + text: ranking.ranked + ? 'Sugerida primero por señales; aprobación antes de envío' + : 'Persona antes de secuencia; aprobación antes de envío', }, - footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, action: { button: 'Elegir secuencia', sections: [{ title: compactForWhatsApp(flow.personaContext, 24), - rows: flow.sequences.map(seq => ({ - id: `owner_seq:${targetContact}:${seq.id}`, - title: compactForWhatsApp(seq.uiTitle || seq.label, 24), - description: compactForWhatsApp(seq.goal, 72), - })), + rows: ranking.ordered.map(seq => { + const reason = ranking.suggestedId === seq.id ? ranking.reasonFor(seq.id) : null; + return { + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(reason ? `Sugerida: ${reason}` : seq.goal, 72), + }; + }), }], }, }, diff --git a/gd-e2e.sh b/gd-e2e.sh new file mode 100644 index 0000000..a90152e --- /dev/null +++ b/gd-e2e.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E G-D — selector de secuencias RECOMENDADO (ranker rule-based, sin LLM). +# 3 perfiles QA: A=deal vivo P7+fact (803), B=referido con quien_intro (805), +# C=sin datos (807). Sims contra produccion, solo numeros QA y owner. +# Uso: bash gd-e2e.sh +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="873315362541590" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado~'$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "$2" "contiene:$3"; fi; } +chk_not(){ if echo "$2" | grep -qF "$3"; then bad "$1" "contiene $3" "sin $3"; else ok "$1"; fi; } + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});await r.text();process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } +WB="wamid.e2e.gd.$(ts)" + +owner_msg_id_by_label(){ + psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} +owner_body_by_label(){ + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} +ranking_of(){ + psql_q "SELECT custom_fields->'ia360_selector_ranking' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$1'" +} + +inject_vcard(){ # $1=nombre $2=numero + local body="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"e2e\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID_NUM\"},\"contacts\":[{\"profile\":{\"name\":\"Alek\"},\"wa_id\":\"$OWNER\"}],\"messages\":[{\"from\":\"$OWNER\",\"id\":\"$WB.vcard.$RANDOM\",\"timestamp\":\"$(ts)\",\"type\":\"contacts\",\"contacts\":[{\"name\":{\"formatted_name\":\"$1\",\"first_name\":\"$1\"},\"phones\":[{\"phone\":\"+$2\",\"wa_id\":\"$2\",\"type\":\"CELL\"}]}]}]},\"field\":\"messages\"}]}]}" + post_webhook "$body" +} +inject_list(){ # $1=reply_id $2=context_msg_id $3=title + local body="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"e2e\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID_NUM\"},\"contacts\":[{\"profile\":{\"name\":\"Alek\"},\"wa_id\":\"$OWNER\"}],\"messages\":[{\"from\":\"$OWNER\",\"id\":\"$WB.$RANDOM\",\"timestamp\":\"$(ts)\",\"type\":\"interactive\",\"context\":{\"id\":\"$2\"},\"interactive\":{\"type\":\"list_reply\",\"list_reply\":{\"id\":\"$1\",\"title\":\"$3\"}}}]},\"field\":\"messages\"}]}]}" + post_webhook "$body" +} + +selector_flow(){ # $1=nombre $2=numero $3=persona_key $4=persona_title + ST=$(inject_vcard "$1" "$2"); chk "vCard HTTP" "$ST" "200" + sleep 7 + CARD1=$(owner_msg_id_by_label "owner_vcard_captured_$2") + if [ -z "$CARD1" ]; then bad "tarjeta vCard capturado" "(vacio)" "message_id"; return; fi + ok "tarjeta vCard capturado ($CARD1)" + ST=$(inject_list "owner_pipe:$2:$3" "$CARD1" "$4"); chk "persona HTTP" "$ST" "200" + sleep 7 +} + +echo "==============================================================" +echo "PERFIL A — deal vivo P7 + fact (5219990000803, persona cliente)" +echo "==============================================================" +selector_flow "QA Cliente Tres" "5219990000803" "persona_cliente" "Cliente activo" +BODY_A=$(owner_body_by_label "owner_sequence_selector_5219990000803_persona_cliente") +RANK_A=$(ranking_of "5219990000803") +chk_has "sugerida = cliente_expansion en chat_history" "$BODY_A" "sugerida: cliente_expansion" +chk_has "razon cita el deal real (Champions G-D)" "$BODY_A" "Champions G-D" +chk_has "ranking persistido: orden inicia con cliente_expansion" "$RANK_A" "\"order\": [\"cliente_expansion\"" +chk_has "resumen linea 1 cita deal y pipeline" "$RANK_A" "Deal vivo:" +chk_has "resumen linea 2 cita el fact real" "$RANK_A" "Reportes manuales" +chk_has "ranked=true" "$RANK_A" "\"ranked\": true" + +echo "==============================================================" +echo "PERFIL B — referido con quien_intro (5219990000805, persona referido)" +echo "==============================================================" +selector_flow "QA Intro Cinco" "5219990000805" "persona_referido" "Referido / BNI" +BODY_B=$(owner_body_by_label "owner_sequence_selector_5219990000805_persona_referido") +RANK_B=$(ranking_of "5219990000805") +chk_has "sugerida = referido_contexto en chat_history" "$BODY_B" "sugerida: referido_contexto" +chk_has "razon cita al introductor real" "$BODY_B" "Te lo presentó QA Fallback Cuatro" +chk_has "ranking persistido: orden inicia con referido_contexto" "$RANK_B" "\"order\": [\"referido_contexto\"" +chk_has "resumen cita al introductor" "$RANK_B" "QA Fallback Cuatro" +chk_has "ranked=true" "$RANK_B" "\"ranked\": true" + +echo "==============================================================" +echo "PERFIL C — contacto sin datos (5219990000807, persona cliente)" +echo "==============================================================" +selector_flow "QA Sin Datos Siete" "5219990000807" "persona_cliente" "Cliente activo" +BODY_C=$(owner_body_by_label "owner_sequence_selector_5219990000807_persona_cliente") +RANK_C=$(ranking_of "5219990000807") +chk_not "SIN sugerida inventada en chat_history" "$BODY_C" "sugerida:" +chk_has "ranked=false" "$RANK_C" "\"ranked\": false" +chk_has "orden default del catalogo (readout primero)" "$RANK_C" "\"order\": [\"cliente_readout\", \"cliente_soporte\", \"cliente_expansion\"]" +chk_has "resumen honesto sin senales" "$RANK_C" "Aún no tengo señales registradas" +chk_not "no afirma introductor" "$RANK_C" "Lo presentó" +chk_not "no afirma deal" "$RANK_C" "Deal vivo:" + +echo "==============================================================" +echo "RESULTADO: PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] && echo "E2E G-D: TODO VERDE" || echo "E2E G-D: HAY FALLAS" From 2e8ec6b857f242a5db8795591210c41dd0ac3419 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Wed, 10 Jun 2026 20:14:34 +0000 Subject: [PATCH 24/39] =?UTF-8?q?test(ia360):=20harness=20QA=20por=20pipel?= =?UTF-8?q?ine=20=E2=80=94=20starters=20reales,=20metadata=20veraz=20y=20m?= =?UTF-8?q?odo=20template=5Fonly=20con=20IMAGE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness scripts/qa-pipeline-harness.sh: Revenue OS E2E via POST /internal/ia360-revenue/opener (22/22 PASS, contacto QA dedicado), repro negativa del bug gate_slots (7/7: ahora_no NO se emite en handoff, fix vigente desde d10dea5), router 100M con anti-loop G-C (7/7), ruteo seq_* (3/3) y cadena cold-send completa via owner_approve_send (11/11). Inyector HMAC con phone_number_id real, eventos marcados entry.id=qa-harness, guard estricto de numeros QA (52199900 + 5 digitos) y aborto si faltan secretos. scripts/qa-template-send.js reemplaza al obsoleto /tmp/send_ia360_pipeline_test.js: metadata veraz obligatoria (pipeline/route_type/test_run/expected_handler en raw_payload.interactive) y soporte de headers IMAGE; template_only nunca cuenta como pipeline probado. Co-Authored-By: Claude Fable 5 --- scripts/qa-pipeline-harness.sh | 354 +++++++++++++++++++++++++++++++++ scripts/qa-template-send.js | 86 ++++++++ 2 files changed, 440 insertions(+) create mode 100755 scripts/qa-pipeline-harness.sh create mode 100644 scripts/qa-template-send.js diff --git a/scripts/qa-pipeline-harness.sh b/scripts/qa-pipeline-harness.sh new file mode 100755 index 0000000..4c20266 --- /dev/null +++ b/scripts/qa-pipeline-harness.sh @@ -0,0 +1,354 @@ +#!/usr/bin/env bash +# ============================================================================ +# qa-pipeline-harness.sh — Harness QA por pipeline (WhatsApp IA360) +# +# Prueba cada pipeline invocando su STARTER/ENDPOINT REAL, nunca TEMPLATE_ID +# directo. Sustituye al helper obsoleto /tmp/send_ia360_pipeline_test.js, que +# contaminaba metadata (pipeline="IA360 100M texto" hardcodeado para todo) y no +# soportaba headers IMAGE. Para envíos de template aislados (modo template_only, +# con soporte IMAGE y metadata veraz) usa scripts/qa-template-send.js. +# +# route_type (clasificación de cada prueba, doc 2026-06-10): +# template_only — solo se mandó el template; NO cuenta como pipeline probado. +# forgechat_monolith_e2e — flujo real del monolito webhook.js (starter + router + estado + deal). +# n8n_brain_v2_staged — ruta canary Brain v2 (owner allowlist), staged. +# new_arch_integrated — arquitectura nueva integrada (aún no aplica en QA). +# +# Subcomandos: +# revenue-os E2E Pipeline "WhatsApp Revenue OS" vía POST +# /api/internal/ia360-revenue/opener (starter real). +# gate-slots-bug Reproducción del bug canon gate_slots (memoria +# ia360-revenue-os-bug): en estado handoff, el click +# "Sí, ver horarios" NO debe emitir ia360_os_revenue_ahora_no. +# Con STALE=1 inyecta además un "Ahora no" viejo +# (OJO: esa rama puede notificar al owner vía fallback global). +# m100 Router 100M del monolito: mapa real, anti-loop G-C, +# "No prioritario" sin botón Aplicarlo. +# seq +# Respuesta del contacto a un opener v2 (ids seq_* G-C). +# cold-send [nombre] +# Cadena cold-send completa vía owner: vCard → persona → +# secuencia → tarjeta de aprobación → owner_approve_send. +# *** EGRESA AL OWNER: correr UNA SOLA VEZ por sesión. *** +# template-only [header_image_url] +# Envío de template aislado con metadata veraz (no +# marca el pipeline como probado). +# +# Solo números QA 52199900*. El bot 5213321594582 nunca es contacto. Cero egress +# a contactos reales. Egress técnico a números QA inexistentes: Meta lo acepta o +# lo marca failed async; lo que se audita es chat_history/estado/deals. +# ============================================================================ +set -uo pipefail + +WA="5213321594582" # wa_number cuenta IA360 (display_phone_number) +OWNER="5213322638033" # owner Alek (solo para el subcomando cold-send) +PID_NUM="873315362541590" # phone_number_id REAL (fix G-D: el inyector debe usarlo) +DB="forgecrm-db" +BE="forgecrm-backend" +REPO="/home/alek/stack/forgechat-poc" +ENVF="$REPO/backend/.env" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +DIR_SECRET="$(grep -E '^IA360_DIRECTIVE_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +# Sin secretos no hay HMAC válido: abortar aquí evita corridas que "pasan" sin autenticar. +[ -n "$APP_SECRET" ] || { echo "ABORT: META_APP_SECRET vacío (revisa $ENVF)" >&2; exit 2; } +[ -n "$DIR_SECRET" ] || { echo "ABORT: IA360_DIRECTIVE_SECRET vacío (revisa $ENVF)" >&2; exit 2; } +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +TEST_RUN="qa-harness.$(date +%s)" +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado~'$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "$2" "contiene:$3"; fi; } +chk_not(){ if echo "$2" | grep -qF "$3"; then bad "$1" "contiene $3" "sin $3"; else ok "$1"; fi; } + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +guard_qa(){ # ningún subcomando acepta números fuera del rango QA; solo dígitos + # (regex estricta: evita basura tras el prefijo que rompería JSON/SQL embebidos) + if ! [[ "$1" =~ ^52199900[0-9]{5}$ ]]; then + echo "ABORT: '$1' no es número QA válido (52199900 + 5 dígitos). Este harness nunca egresa a contactos reales." >&2 + exit 2 + fi +} + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;if(process.env.SECRET)h["X-IA360-Directive-Secret"]=process.env.SECRET;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status)+(process.env.SHOWBODY?(" "+t):""));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } +wamid(){ echo "wamid.${TEST_RUN}.$1.$(ts).$RANDOM"; } + +# Inyectores HMAC. entry.id="qa-harness" marca provenance veraz del evento sintético. +_envelope(){ # $1=from $2=profile $3=messages_json + printf '{"object":"whatsapp_business_account","entry":[{"id":"qa-harness","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"%s","phone_number_id":"%s"},"contacts":[{"wa_id":"%s","profile":{"name":"%s"}}],"messages":[%s]}}]}]}' "$WA" "$PID_NUM" "$1" "$2" "$3" +} +inject_tpl_button(){ # $1=from $2=payload_texto (quick reply de template) $3=profile + post_webhook "$(_envelope "$1" "${3:-QA Harness}" "{\"from\":\"$1\",\"id\":\"$(wamid tplbtn)\",\"timestamp\":\"$(ts)\",\"type\":\"button\",\"button\":{\"text\":\"$2\",\"payload\":\"$2\"}}")" +} +inject_button(){ # $1=from $2=reply_id $3=title $4=profile + post_webhook "$(_envelope "$1" "${4:-QA Harness}" "{\"from\":\"$1\",\"id\":\"$(wamid btn)\",\"timestamp\":\"$(ts)\",\"type\":\"interactive\",\"interactive\":{\"type\":\"button_reply\",\"button_reply\":{\"id\":\"$2\",\"title\":\"$3\"}}}")" +} +inject_text(){ # $1=from $2=texto $3=profile $4=wamid (pásalo si luego auditas por ia360_handler_for) + local id="${4:-$(wamid txt)}" + post_webhook "$(_envelope "$1" "${3:-QA Harness}" "{\"from\":\"$1\",\"id\":\"$id\",\"timestamp\":\"$(ts)\",\"type\":\"text\",\"text\":{\"body\":\"$2\"}}")" +} +inject_list(){ # $1=from $2=row_id $3=context_msg_id $4=title $5=profile + post_webhook "$(_envelope "$1" "${5:-QA Harness}" "{\"from\":\"$1\",\"id\":\"$(wamid list)\",\"timestamp\":\"$(ts)\",\"type\":\"interactive\",\"context\":{\"id\":\"$3\"},\"interactive\":{\"type\":\"list_reply\",\"list_reply\":{\"id\":\"$2\",\"title\":\"$4\"}}}")" +} +inject_vcard(){ # $1=from(owner) $2=nombre $3=numero + post_webhook "$(_envelope "$1" "Alek" "{\"from\":\"$1\",\"id\":\"$(wamid vcard)\",\"timestamp\":\"$(ts)\",\"type\":\"contacts\",\"contacts\":[{\"name\":{\"formatted_name\":\"$2\",\"first_name\":\"$2\"},\"phones\":[{\"phone\":\"+$3\",\"wa_id\":\"$3\",\"type\":\"CELL\"}]}]}")" +} + +# Lecturas de auditoría +max_out_id(){ psql_q "SELECT COALESCE(MAX(id),0) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1'"; } +fresh_out_label(){ # $1=contact $2=label $3=base_id -> message_body de la saliente NUEVA con ese label + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' AND id>$3 AND template_meta->>'label'='$2' ORDER BY id DESC LIMIT 1" +} +fresh_count_label(){ psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' AND id>$3 AND template_meta->>'label'='$2'"; } +fresh_labels(){ psql_q "SELECT string_agg(template_meta->>'label', ', ' ORDER BY id) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' AND id>$2"; } +rev_state(){ psql_q "SELECT custom_fields->>'ia360_revenue_state' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$1'"; } +p5_stage(){ psql_q "SELECT s.name FROM coexistence.deals d JOIN coexistence.pipeline_stages s ON s.id=d.stage_id JOIN coexistence.pipelines p ON p.id=d.pipeline_id WHERE p.name='WhatsApp Revenue OS' AND d.contact_number='$1' ORDER BY d.updated_at DESC NULLS LAST, d.id DESC LIMIT 1"; } +cf_of(){ psql_q "SELECT custom_fields->>'$2' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$1'"; } + +banner(){ + echo "==============================================================" + echo "$1" + echo " test_run=$TEST_RUN route_type=$2" + echo "==============================================================" +} +verdict(){ + echo "" + echo "--------------------------------------------------------------" + echo " RESULTADO ($1): PASS=$PASS FAIL=$FAIL test_run=$TEST_RUN" + echo "--------------------------------------------------------------" + [ "$FAIL" -eq 0 ] || exit 1 +} + +# ─────────────────────────────────────────────────────────────────────────── +cmd_revenue_os(){ + local QA="$1"; guard_qa "$QA" + banner "REVENUE OS E2E — starter real POST /internal/ia360-revenue/opener — contacto QA $QA" "forgechat_monolith_e2e" + + echo "--- STEP 0: limpieza de estado P5 del contacto QA (no toca P2 ni otros contactos) ---" + psql_q "DELETE FROM coexistence.deals WHERE contact_number='$QA' AND pipeline_id=(SELECT id FROM coexistence.pipelines WHERE name='WhatsApp Revenue OS')" >/dev/null + psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields - 'ia360_revenue_state' - 'ia360_revenue_dolor' - 'ia360_revenue_canal' - 'ia360_revenue_volumen' - 'ia360_revenue_calificacion_raw' - 'ia360_revenue_started_at' WHERE wa_number='$WA' AND contact_number='$QA'" >/dev/null + local BASE_ID; BASE_ID="$(max_out_id "$QA")" + ok "estado P5 limpio (base chat_history id=$BASE_ID)" + + echo "--- PASO 1: opener vía endpoint real ---" + local RES; RES="$(printf '%s' "{\"contact_number\":\"$QA\",\"name\":\"QA Revenue OS\"}" | docker exec -i -e SECRET="$DIR_SECRET" -e URL="$BASE/internal/ia360-revenue/opener" -e SHOWBODY=1 "$BE" node -e "$NODE_POST")" + echo " opener resp: $RES" + chk_has "PASO1 endpoint respondió ok" "$RES" '"ok":true' + sleep 6 + chk "PASO1 estado=apertura_sent" "$(rev_state "$QA")" "apertura_sent" + chk "PASO1 deal P5 en 'Leads desorganizados'" "$(p5_stage "$QA")" "Leads desorganizados" + local APERTURA; APERTURA="$(psql_q "SELECT status||' | '||message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND id>$BASE_ID AND template_meta->>'template_name'='ia360_os_revenue_apertura' ORDER BY id DESC LIMIT 1")" + echo " apertura chat_history: $APERTURA" + chk_has "PASO1 template apertura registrado" "$APERTURA" "soy la IA de Alek" + + echo "--- PASO 1→2: tap 'Sí, cuéntame' (quick reply del template) ---" + chk "PASO1→2 HTTP" "$(inject_tpl_button "$QA" "Sí, cuéntame" "QA Revenue OS")" "200" + sleep 5 + chk "PASO2 estado=calificacion" "$(rev_state "$QA")" "calificacion" + local P2; P2="$(fresh_out_label "$QA" ia360_os_revenue_paso2 "$BASE_ID")" + echo " paso2: $P2" + chk_has "PASO2 pregunta de calificación" "$P2" "rastro" + + echo "--- PASO 2→3: texto libre de calificación ---" + local W2; W2="$(wamid calif)" + chk "PASO2→3 HTTP" "$(inject_text "$QA" "Todo va en un Excel y la memoria, se nos caen como 15 leads al mes" "QA Revenue OS" "$W2")" "200" + sleep 6 + chk "PASO3 estado=propuesta" "$(rev_state "$QA")" "propuesta" + chk_has "PASO2 dolor capturado" "$(cf_of "$QA" ia360_revenue_dolor)" "Excel" + local P3; P3="$(fresh_out_label "$QA" ia360_os_revenue_paso3 "$BASE_ID")" + echo " paso3: $P3" + chk_has "PASO3 botón 'Ver cómo se vería'" "$P3" "Ver cómo se vería" + chk_has "PASO3 botón 'Hablar con Alek'" "$P3" "Hablar con Alek" + chk "PASO2 el agente genérico NO respondió (gate cortó)" "$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND template_meta->>'ia360_handler_for'='$W2' AND template_meta->>'label' LIKE 'ia360_ai_%'")" "0" + + echo "--- PASO 3 rama A: 'Ver cómo se vería' → demo + deal a Diseño propuesto ---" + chk "RAMA-A HTTP" "$(inject_button "$QA" "revenue_ver_demo" "Ver cómo se vería" "QA Revenue OS")" "200" + sleep 5 + chk "RAMA-A estado=demo" "$(rev_state "$QA")" "demo" + chk "RAMA-A deal P5 → 'Diseño propuesto'" "$(p5_stage "$QA")" "Diseño propuesto" + chk "RAMA-A demo enviado" "$(fresh_count_label "$QA" ia360_os_revenue_demo "$BASE_ID")" "1" + + echo "--- reset a 'propuesta' para rama B (handoff) ---" + psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields || '{\"ia360_revenue_state\":\"propuesta\"}'::jsonb WHERE wa_number='$WA' AND contact_number='$QA'" >/dev/null + chk "RAMA-B HTTP" "$(inject_button "$QA" "revenue_hablar_alek" "Hablar con Alek" "QA Revenue OS")" "200" + sleep 5 + chk "RAMA-B estado=handoff" "$(rev_state "$QA")" "handoff" + local GATE; GATE="$(fresh_out_label "$QA" ia360_os_revenue_gate_agenda "$BASE_ID")" + echo " gate: $GATE" + chk_has "RAMA-B compuerta de agenda con botones" "$GATE" "Sí, ver horarios" + + echo "--- Cero mezcla con el router 100M ---" + chk "ninguna saliente ia360_100m_* en toda la corrida" "$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND id>$BASE_ID AND template_meta->>'label' LIKE 'ia360_100m%'")" "0" + echo " labels de la corrida: $(fresh_labels "$QA" "$BASE_ID")" + verdict "Revenue OS E2E ($QA)" +} + +# ─────────────────────────────────────────────────────────────────────────── +cmd_gate_slots_bug(){ + local QA="$1"; guard_qa "$QA" + banner "BUG gate_slots (memoria ia360-revenue-os-bug) — contacto QA $QA" "forgechat_monolith_e2e" + local ST; ST="$(rev_state "$QA")" + if [ "$ST" != "handoff" ]; then + echo " [WARN] estado actual='$ST' ≠ handoff → lo siembro por SQL para la reproducción aislada" + psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields || '{\"ia360_revenue_state\":\"handoff\"}'::jsonb WHERE wa_number='$WA' AND contact_number='$QA'" >/dev/null + fi + local BASE_ID; BASE_ID="$(max_out_id "$QA")" + + echo "--- Repro 1: click 'Sí, ver horarios' (gate_slots_yes) en estado handoff ---" + echo " Bug original 2026-06-09 00:30: este click emitía TAMBIÉN ia360_os_revenue_ahora_no." + chk "R1 HTTP" "$(inject_button "$QA" "gate_slots_yes" "Sí, ver horarios" "QA Revenue OS")" "200" + sleep 8 + chk "R1 ahora_no NO emitido" "$(fresh_count_label "$QA" ia360_os_revenue_ahora_no "$BASE_ID")" "0" + chk "R1 estado sigue handoff (sin fuga a nutricion)" "$(rev_state "$QA")" "handoff" + local SLOTS; SLOTS="$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND id>$BASE_ID AND template_meta->>'label' IN ('ia360_lite_available_slots_reslots','ia360_lite_reslots_none')")" + chk "R1 respuesta de horarios (slots reales o aviso sin huecos)" "$SLOTS" "1" + echo " labels tras R1: $(fresh_labels "$QA" "$BASE_ID")" + + if [ "${STALE:-0}" = "1" ]; then + echo "--- Repro 2 (STALE=1): quick reply viejo 'Ahora no' en estado handoff ---" + echo " OJO: si cae al fallback global, notifica al owner (corre esto solo en el bloque final)." + local BASE2; BASE2="$(max_out_id "$QA")" + chk "R2 HTTP" "$(inject_tpl_button "$QA" "Ahora no" "QA Revenue OS")" "200" + sleep 6 + chk "R2 ahora_no NO emitido (gateo por estado funciona)" "$(fresh_count_label "$QA" ia360_os_revenue_ahora_no "$BASE2")" "0" + chk "R2 estado NO cayó a nutricion" "$(rev_state "$QA")" "handoff" + echo " respuesta real a R2: $(fresh_labels "$QA" "$BASE2")" + else + echo " (Repro 2 'Ahora no' stale omitida; corre con STALE=1 en el bloque final con egress al owner)" + fi + verdict "bug gate_slots ($QA)" +} + +# ─────────────────────────────────────────────────────────────────────────── +cmd_m100(){ + local QA="$1"; guard_qa "$QA" + banner "ROUTER 100M (monolito) — contacto QA $QA" "forgechat_monolith_e2e" + psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields - 'ia360_100m_visited' WHERE wa_number='$WA' AND contact_number='$QA'" >/dev/null + local BASE_ID; BASE_ID="$(max_out_id "$QA")" + + echo "--- T1: 'Quiero mapa' entrega mapa real (guardrail f4b56b2, sin offer_router) ---" + chk "T1 HTTP" "$(inject_button "$QA" "100m_want_map" "Quiero mapa" "QA Cien Eme")" "200" + sleep 6 + local L1; L1="$(fresh_labels "$QA" "$BASE_ID")" + echo " labels: $L1" + chk_has "T1 responde la rama de mapa" "$L1" "mapa" + chk_not "T1 NO abre el Flow ia360_offer_router" "$L1" "offer_router" + + echo "--- T2: anti-loop G-C — segunda visita al mismo nodo → versión condensada ---" + local BASE2; BASE2="$(max_out_id "$QA")" + chk "T2 HTTP" "$(inject_button "$QA" "100m_want_map" "Quiero mapa" "QA Cien Eme")" "200" + sleep 6 + chk "T2 respuesta condensada (ia360_100m_condensed)" "$(fresh_count_label "$QA" ia360_100m_condensed "$BASE2")" "1" + + echo "--- T3: 'No prioritario' sin botón 'Aplicarlo' (anti-loop G-C) ---" + local BASE3; BASE3="$(max_out_id "$QA")" + chk "T3 HTTP" "$(inject_button "$QA" "100m_not_priority" "No prioritario" "QA Cien Eme")" "200" + sleep 6 + local R3; R3="$(psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND id>$BASE3 ORDER BY id DESC LIMIT 1")" + echo " respuesta: $R3" + chk_not "T3 sin 'Aplicarlo'" "$R3" "Aplicarlo" + verdict "router 100M ($QA)" +} + +# ─────────────────────────────────────────────────────────────────────────── +cmd_seq(){ + local QA="$1" SEQ="$2" OPT="$3" TITLE="$4"; guard_qa "$QA" + banner "OPENERS V2 — respuesta seq_${SEQ}:${OPT} — contacto QA $QA" "forgechat_monolith_e2e" + psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields - 'ia360_seq_last_response' WHERE wa_number='$WA' AND contact_number='$QA'" >/dev/null + local BASE_ID; BASE_ID="$(max_out_id "$QA")" + chk "HTTP" "$(inject_button "$QA" "seq_${SEQ}:${OPT}" "$TITLE")" "200" + sleep 6 + local LBL; LBL="$(fresh_labels "$QA" "$BASE_ID")" + echo " labels: $LBL" + chk_has "router seq_* respondió (label ia360_seq_*)" "$LBL" "ia360_seq_" + chk_has "respuesta registrada en custom_fields" "$(cf_of "$QA" ia360_seq_last_response)" "$OPT" + verdict "seq ${SEQ}:${OPT} ($QA)" +} + +# ─────────────────────────────────────────────────────────────────────────── +cmd_cold_send(){ + local QA="$1" PERSONA="$2" SEQ="$3" NAME="${4:-QA Cold Send}"; guard_qa "$QA" + banner "COLD-SEND vía owner (starter real de secuencias) — $PERSONA/$SEQ → $QA" "forgechat_monolith_e2e" + echo " *** Este subcomando EGRESA AL OWNER (tarjetas + confirmación). Una sola corrida por sesión. ***" + local BASE_OWNER; BASE_OWNER="$(max_out_id "$OWNER")" + local BASE_QA; BASE_QA="$(max_out_id "$QA")" + + echo "--- C1: vCard del owner → tarjeta de captura ---" + chk "C1 HTTP" "$(inject_vcard "$OWNER" "$NAME" "$QA")" "200" + sleep 7 + local CARD; CARD="$(psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND id>$BASE_OWNER AND template_meta->>'label'='owner_vcard_captured_$QA' ORDER BY id DESC LIMIT 1")" + if [ -z "$CARD" ]; then bad "C1 tarjeta vCard" "(vacío)" "message_id"; verdict "cold-send"; return; fi + ok "C1 tarjeta vCard ($CARD)" + + echo "--- C2: persona $PERSONA → selector de secuencias ---" + chk "C2 HTTP" "$(inject_list "$OWNER" "owner_pipe:$QA:$PERSONA" "$CARD" "Persona" "Alek")" "200" + sleep 8 + local SEL; SEL="$(psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND id>$BASE_OWNER AND template_meta->>'label'='owner_sequence_selector_${QA}_${PERSONA}' ORDER BY id DESC LIMIT 1")" + if [ -z "$SEL" ]; then bad "C2 selector" "(vacío)" "message_id"; verdict "cold-send"; return; fi + ok "C2 selector de secuencias ($SEL)" + + echo "--- C3: secuencia $SEQ → readout + tarjeta de aprobación ---" + chk "C3 HTTP" "$(inject_list "$OWNER" "owner_seq:$QA:$SEQ" "$SEL" "Secuencia" "Alek")" "200" + sleep 8 + local APR; APR="$(psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND id>$BASE_OWNER AND template_meta->>'label'='owner_approve_card_${QA}_${SEQ}' ORDER BY id DESC LIMIT 1")" + if [ -z "$APR" ]; then bad "C3 tarjeta de aprobación" "(vacío)" "message_id"; verdict "cold-send"; return; fi + ok "C3 tarjeta de aprobación ($APR)" + + echo "--- C4: owner_approve_send → opener real al contacto QA ---" + chk "C4 HTTP" "$(inject_list "$OWNER" "owner_approve_send:$QA:$SEQ" "$APR" "Aprobar y enviar" "Alek")" "200" + sleep 8 + local DONE; DONE="$(psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND id>$BASE_OWNER AND template_meta->>'label' IN ('owner_approve_send_done','owner_approve_send_failed') ORDER BY id DESC LIMIT 1")" + echo " resultado owner: $DONE" + chk_has "C4 confirmación de envío al owner" "$DONE" "Envié el opener" + local OPENER; OPENER="$(psql_q "SELECT template_meta->>'label'||' | '||status FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND id>$BASE_QA ORDER BY id DESC LIMIT 1")" + echo " opener al QA: $OPENER" + chk_has "C4 opener registrado para el contacto" "$OPENER" "ia360" + echo " deal del QA: $(psql_q "SELECT p.name||' / '||s.name FROM coexistence.deals d JOIN coexistence.pipeline_stages s ON s.id=d.stage_id JOIN coexistence.pipelines p ON p.id=d.pipeline_id WHERE d.contact_number='$QA' ORDER BY d.updated_at DESC NULLS LAST, d.id DESC LIMIT 1")" + + echo "--- C5: respuesta del contacto al opener ('Sí, cuéntame' → alias seq_*) ---" + local BASE_QA2; BASE_QA2="$(max_out_id "$QA")" + chk "C5 HTTP" "$(inject_tpl_button "$QA" "Sí, cuéntame" "$NAME")" "200" + sleep 6 + local L5; L5="$(fresh_labels "$QA" "$BASE_QA2")" + echo " labels: $L5" + chk_has "C5 alias ruteado al paso 2 de la secuencia" "$L5" "ia360_seq_" + verdict "cold-send $PERSONA/$SEQ ($QA)" +} + +# ─────────────────────────────────────────────────────────────────────────── +cmd_template_only(){ + local QA="$1" TPL="$2" PIPE="$3" IMG="${4:-}"; guard_qa "$QA" + banner "TEMPLATE-ONLY — $TPL → $QA (NO cuenta como pipeline probado)" "template_only" + docker cp "$REPO/scripts/qa-template-send.js" "$BE":/app/qa-template-send.js >/dev/null + docker exec -e TEMPLATE_NAME="$TPL" -e TO="$QA" -e PIPELINE="$PIPE" -e TEST_RUN="$TEST_RUN" \ + -e EXPECTED_HANDLER="${EXPECTED_HANDLER:-}" -e HEADER_IMAGE_URL="$IMG" \ + -e SAMPLE_VALUES="${SAMPLE_VALUES:-{\"1\":\"QA\"}}" "$BE" node /app/qa-template-send.js + local RC=$? + chk "encolado template_only" "$RC" "0" + sleep 5 + echo " chat_history: $(psql_q "SELECT status||' | '||COALESCE(error_message,'-')||' | '||(raw_payload->'interactive'->>'pipeline')||' | '||(raw_payload->'interactive'->>'route_type') FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND raw_payload->'interactive'->>'test_run'='$TEST_RUN' ORDER BY id DESC LIMIT 1")" + verdict "template-only $TPL ($QA)" +} + +# ─────────────────────────────────────────────────────────────────────────── +case "${1:-help}" in + revenue-os) cmd_revenue_os "${2:?num_qa}";; + gate-slots-bug) cmd_gate_slots_bug "${2:?num_qa}";; + m100) cmd_m100 "${2:?num_qa}";; + seq) cmd_seq "${2:?num_qa}" "${3:?seq_id}" "${4:?opcion}" "${5:?titulo}";; + cold-send) cmd_cold_send "${2:?num_qa}" "${3:?persona}" "${4:?seq_id}" "${5:-}";; + template-only) cmd_template_only "${2:?num_qa}" "${3:?template_name}" "${4:?pipeline}" "${5:-}";; + *) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'; exit 0;; +esac diff --git a/scripts/qa-template-send.js b/scripts/qa-template-send.js new file mode 100644 index 0000000..3316ffe --- /dev/null +++ b/scripts/qa-template-send.js @@ -0,0 +1,86 @@ +// ============================================================================ +// qa-template-send.js — envío de template en modo "template_only" (QA harness) +// +// REEMPLAZA al helper obsoleto /tmp/send_ia360_pipeline_test.js, que: +// 1) hardcodeaba rawPayloadExtra.pipeline = "IA360 100M texto" para CUALQUIER +// template (contaminó metadata de Lite, Revenue OS y Referidos en la +// corrida 2026-06-10, chat_history 1143/1165/1167), y +// 2) no soportaba headers IMAGE (los visuales ia360_100m_img_* fallaban con +// #132012 antes de Meta). +// +// REGLA DE ORO: enviar un template NO prueba un pipeline. Este modo existe solo +// para validar render/entrega de templates aislados; los pipelines se prueban +// con sus starters reales vía scripts/qa-pipeline-harness.sh (route_type +// forgechat_monolith_e2e / n8n_brain_v2_staged / new_arch_integrated). +// +// Uso (dentro del contenedor backend, copiado por el harness): +// TEMPLATE_NAME=ia360_100m_img_01_dolor TO=5219990000911 \ +// PIPELINE="IA360 100M visual" TEST_RUN=qa.123 EXPECTED_HANDLER=router_100m \ +// HEADER_IMAGE_URL=https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg \ +// SAMPLE_VALUES='{"1":"QA"}' node /app/qa-template-send.js +// ============================================================================ +const pool = require('./src/db'); +const { resolveAccount, insertPendingRow } = require('./src/services/messageSender'); +const { enqueueSend } = require('./src/queue/sendQueue'); + +function extractVars(t) { return [...(t || '').matchAll(/\{\{(\d+)\}\}/g)].map(m => m[1]).sort((a, b) => +a - +b); } + +(async () => { + const templateName = process.env.TEMPLATE_NAME || ''; + const templateId = Number(process.env.TEMPLATE_ID || 0); + const to = String(process.env.TO || '').replace(/\D/g, ''); + const pipeline = String(process.env.PIPELINE || '').trim(); + const testRun = String(process.env.TEST_RUN || '').trim(); + const expectedHandler = String(process.env.EXPECTED_HANDLER || '').trim(); + const headerImageUrl = String(process.env.HEADER_IMAGE_URL || '').trim(); + const sampleValues = JSON.parse(process.env.SAMPLE_VALUES || '{"1":"QA"}'); + + // Metadata veraz u honestamente vacía: nunca un default que mienta. + if (!pipeline) throw new Error('PIPELINE es obligatorio (el helper viejo lo hardcodeaba a "IA360 100M texto"; aquí se declara el real)'); + if (!testRun) throw new Error('TEST_RUN es obligatorio (id de corrida para auditoría)'); + if (!to.startsWith('52199900')) throw new Error(`TO=${to} no es un número QA (52199900*). Este modo nunca egresa a contactos reales.`); + + const { rows } = templateName + ? await pool.query('SELECT id,name,language,body,header_type,whatsapp_account_id FROM coexistence.message_templates WHERE name=$1 ORDER BY id DESC LIMIT 1', [templateName]) + : await pool.query('SELECT id,name,language,body,header_type,whatsapp_account_id FROM coexistence.message_templates WHERE id=$1', [templateId]); + if (!rows.length) throw new Error('template no encontrado: ' + (templateName || templateId)); + const tpl = rows[0]; + + const { account, error } = await resolveAccount({ accountId: tpl.whatsapp_account_id }); + if (error) throw new Error(error); + + const vars = extractVars(tpl.body); + const missing = vars.filter(v => !String(sampleValues[v] ?? '').trim()); + if (missing.length) throw new Error('faltan variables ' + missing.join(',')); + const renderedBody = vars.reduce((acc, v) => acc.split('{{' + v + '}}').join(String(sampleValues[v])), tpl.body || ''); + + const components = []; + // Soporte de header IMAGE: el helper viejo no lo tenía y los visuales morían + // en el validador (#132012). Si el template lo exige y no hay URL, se corta + // aquí con un error claro en vez de encolar un envío condenado. + if (String(tpl.header_type || '').toUpperCase() === 'IMAGE') { + if (!headerImageUrl) throw new Error(`template "${tpl.name}" tiene header IMAGE: pasa HEADER_IMAGE_URL`); + components.push({ type: 'header', parameters: [{ type: 'image', image: { link: headerImageUrl } }] }); + } + if (vars.length) components.push({ type: 'body', parameters: vars.map(v => ({ type: 'text', text: String(sampleValues[v]) })) }); + + const localId = await insertPendingRow({ + account, + toNumber: to, + messageType: 'template', + messageBody: renderedBody, + rawPayloadExtra: { + qa_harness: true, + test_run: testRun, + route_type: 'template_only', + pipeline, + template: tpl.name, + expected_handler: expectedHandler || null, + header_image_url: headerImageUrl || null, + note: 'template_only NO cuenta como pipeline probado; ver scripts/qa-pipeline-harness.sh', + }, + }); + await enqueueSend({ kind: 'template', accountId: account.id, to, localMessageId: localId, payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components } }); + console.log(JSON.stringify({ ok: true, localId, template: tpl.name, header_type: tpl.header_type, to, route_type: 'template_only', test_run: testRun, pipeline }, null, 2)); + setTimeout(() => process.exit(0), 300); +})().catch(e => { console.error(e.stack || e.message); process.exit(1); }); From f4f34fb3d9ea7bd56a67cb694508d215dc39ec69 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Thu, 11 Jun 2026 00:00:34 +0000 Subject: [PATCH 25/39] feat(ia360): G-WIN quick-win mapa de cartera (P7 Champions, persona cliente/CFO) PASO 1: saldos que no cuadran -> Hallazgo/Impacto/Dato faltante (tabla EN TEXTO; el bot no lee imagenes)/Siguiente accion, sin pitch y sin agenda. PASO 2: tabla pegada -> mapa estructurado + nota completa en deal P7 + cola ia360_docs_sync (AlekContenido) + readout al owner; deal avanza a Quick win entregado (solo hacia adelante). Handler de media pide version en texto. Gates: tema cartera + persona cliente_activo; owner nunca entra. Endpoint /internal/ia360-cartera/preview (copy de los 3 pasos al owner). Harness scripts/qa-cartera-quickwin.sh: E2E 30/30 PASS en produccion (forgechat_monolith_e2e, solo numeros QA; cero egress a Andres real). Deudas documentadas (no bloquean): dedupe en rafaga del PASO 2, celdas vacias recorren columnas, filas descartadas sin aviso, mapa >4096 chars. Co-Authored-By: Claude Fable 5 --- backend/src/routes/webhook.js | 427 ++++++++++++++++++++++++++++++++- scripts/qa-cartera-quickwin.sh | 164 +++++++++++++ 2 files changed, 590 insertions(+), 1 deletion(-) create mode 100755 scripts/qa-cartera-quickwin.sh diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index fc11792..9a49218 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -1366,6 +1366,359 @@ async function handleRevenueOsFreeText(record) { } } +// ============================================================================ +// G-WIN — Quick-win "Mapa de cartera" (Pipeline 7 "Champions — Adopción y +// expansión", persona cliente activo / CFO). Patrón Revenue OS P5: máquina de +// estados en contacts.custom_fields.ia360_cartera_state ('' → esperando_tabla +// → mapa_entregado), handlers gateados que CORTAN el embudo, egress único vía +// enqueueIa360Text / sendIa360DirectText → sendQueue. +// PASO 1 (texto cartera/saldos que no cuadran) → Hallazgo / Impacto / Dato +// faltante (pide la tabla EN TEXTO; el bot no lee imágenes) / +// Siguiente acción. SIN pitch y SIN agenda. +// PASO 2 (tabla pegada en texto) → mapa estructurado al contacto + nota +// completa en su deal P7 + cola ia360_docs_sync + readout al owner + +// deal a "Quick win entregado" (solo hacia adelante). +// GUARDRAIL: nunca agenda automática, no nutrición, no insistencia; si el +// mensaje no es de cartera, el flujo NO se activa y el agente genérico sigue. +// ============================================================================ +const CHAMPIONS_PIPELINE_NAME = 'Champions — Adopción y expansión'; +const CARTERA_STAGE_VALIDACION = 'Validación en curso'; +const CARTERA_STAGE_QUICKWIN = 'Quick win entregado'; +const CARTERA_FORMATO_TABLA = 'Cliente | Saldo en portal | Saldo correcto | Fecha de corte | Responsable'; + +const IA360_CARTERA_COPY = { + paso1: [ + 'Gracias por el aviso. Lo dejo ordenado:', + '', + '*Hallazgo:* los saldos que muestra el portal no cuadran con los saldos reales de cartera; hoy la corrección depende de revisiones manuales y la diferencia no se ve en un solo lugar.', + '', + '*Impacto:* mientras el portal muestre saldos incorrectos, cobranza trabaja con cifras que el cliente puede rebatir y el seguimiento pierde confiabilidad.', + '', + '*Dato faltante:* mándame la tabla aquí mismo, en texto, una línea por cuenta con este formato:', + CARTERA_FORMATO_TABLA, + 'Importante: no puedo leer imágenes ni archivos adjuntos; si la tienes en foto o en Excel, pégamela como texto.', + '', + '*Siguiente acción:* en cuanto la reciba, la convierto en tu mapa de cartera (cuenta → saldo portal → saldo correcto → fecha de corte → responsable → siguiente acción) y lo dejo registrado para Alek.', + ].join('\n'), + pideTexto: [ + 'Recibí tu archivo, pero no puedo leer imágenes ni documentos adjuntos.', + '¿Me pegas la tabla aquí mismo en texto? Una línea por cuenta:', + CARTERA_FORMATO_TABLA, + ].join('\n'), + recordatorioFormato: [ + 'Va, sigo pendiente de la tabla para armar el mapa. Pégala aquí en texto, una línea por cuenta:', + CARTERA_FORMATO_TABLA, + ].join('\n'), +}; + +// Persona cliente activo / CFO: reúsa el helper beta (Andrés) y el perfil +// persona-first (QA y contactos nuevos). El owner JAMÁS entra a este flujo. +function ia360IsClienteActivoCartera(contact) { + if (!contact) return false; + if (isIa360ClienteActivoBetaContact(contact)) return true; + const cf = contact.custom_fields || {}; + const rel = cf?.ia360_persona_first?.classification?.relationship_context || ''; + const personaCtx = String(cf.persona_context || '').toLowerCase(); + const tags = Array.isArray(contact.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return rel === 'cliente_activo' + || personaCtx === 'cliente activo' + || tags.includes('persona:cliente_activo'); +} + +// Disparador del PASO 1: cartera/cobranza explícita, o "saldos" acompañado de +// señal de descuadre. Mantenerlo angosto: un tema no-cartera NO debe activar +// el flujo (gate del goal). +const IA360_CARTERA_TRIGGER_RE = /\b(cartera|cobranza|cuentas?\s+por\s+cobrar)\b/i; +const IA360_CARTERA_SALDOS_RE = /\bsaldos?\b/i; +const IA360_CARTERA_DESCUADRE_RE = /no\s+cuadra|descuadr|incorrect|equivocad|diferenc|portal|\bmal\b/i; + +function ia360EsMensajeCartera(body) { + const t = String(body || ''); + return IA360_CARTERA_TRIGGER_RE.test(t) + || (IA360_CARTERA_SALDOS_RE.test(t) && IA360_CARTERA_DESCUADRE_RE.test(t)); +} + +// Parser de la tabla pegada en texto. Separadores: | ; tab. Coma solo si la +// línea no trae montos con coma de millares ("1,250,000"). Salta encabezados. +function parseCarteraTabla(text) { + const rows = []; + const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean); + for (const line of lines) { + let parts = null; + if (/[|;\t]/.test(line)) parts = line.split(/[|;\t]/); + else if (line.includes(',') && !/\d,\d{3}/.test(line)) parts = line.split(','); + if (!parts) continue; + parts = parts.map(p => p.trim()).filter(p => p !== ''); + if (parts.length < 4) continue; + const low = line.toLowerCase(); + if (/cliente|cuenta/.test(low) && /saldo/.test(low)) continue; // encabezado + rows.push({ + cuenta: parts[0], + saldo_portal: parts[1], + saldo_correcto: parts[2], + fecha_corte: parts[3], + responsable: parts[4] || 'por confirmar', + }); + } + return rows; +} + +function carteraMonto(s) { + const limpio = String(s || '').replace(/[^0-9.\-]/g, ''); + if (!limpio || limpio === '-' || limpio === '.') return null; + const n = Number(limpio); + return Number.isFinite(n) ? n : null; +} + +function carteraFormatoMonto(n) { + if (n === null || !Number.isFinite(n)) return null; + const negativo = n < 0; + const [ent, dec] = Math.abs(n).toFixed(2).split('.'); + return `${negativo ? '-' : ''}$${ent.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${dec}`; +} + +// Mapa estructurado: cuenta → saldo portal → saldo correcto → diferencia → +// fecha de corte → responsable → siguiente acción. +function buildCarteraMapa(rows) { + const bloques = []; + let diferenciaTotal = 0; + let cuentasConDescuadre = 0; + rows.forEach((r, i) => { + const portal = carteraMonto(r.saldo_portal); + const correcto = carteraMonto(r.saldo_correcto); + const dif = portal !== null && correcto !== null ? correcto - portal : null; + if (dif !== null) { + diferenciaTotal += dif; + if (dif !== 0) cuentasConDescuadre += 1; + } + bloques.push([ + `${i + 1}) Cuenta: ${r.cuenta}`, + ` - Saldo en portal: ${carteraFormatoMonto(portal) || r.saldo_portal}`, + ` - Saldo correcto: ${carteraFormatoMonto(correcto) || r.saldo_correcto}`, + ` - Diferencia: ${dif !== null ? carteraFormatoMonto(dif) : 'por calcular'}`, + ` - Fecha de corte: ${r.fecha_corte}`, + ` - Responsable: ${r.responsable}`, + ` - Siguiente acción: corregir el saldo en el portal y confirmarlo con ${r.responsable} antes del próximo corte.`, + ].join('\n')); + }); + const texto = [ + '*Mapa de cartera — saldos por corregir*', + '', + bloques.join('\n\n'), + '', + `Cuentas con descuadre: ${cuentasConDescuadre} de ${rows.length} · Diferencia acumulada: ${carteraFormatoMonto(diferenciaTotal) || 'por calcular'}`, + '', + 'Ya quedó registrado para Alek con el detalle completo. Cuando el portal refleje los saldos correctos, este mapa sirve para confirmarlo cuenta por cuenta.', + ].join('\n'); + return { texto, diferenciaTotal, cuentasConDescuadre }; +} + +// Movimiento de deal dedicado a Pipeline 7 (clon del patrón syncRevenueOsDeal: +// create-or-move, solo hacia adelante por posición; NO toca otros pipelines). +async function syncCarteraChampionsDeal({ record, targetStageName, notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [CHAMPIONS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const title = `IA360 · ${contactName} · Quick win cartera`; + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name, title }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + notes = $3, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $4`, + [finalStageId, shouldMove ? finalStatus : existing.status, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name, title: existing.title }; +} + +// Media (imagen/documento) durante esperando_tabla → pedir la versión en texto. +// El bot no descarga ni interpreta el archivo; solo guía al contacto. +async function handleCarteraMediaInbound(record) { + try { + if (!record || record.direction !== 'incoming') return false; + if (record.message_type !== 'image' && record.message_type !== 'document') return false; + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || !ia360IsClienteActivoCartera(contact)) return false; + if ((contact.custom_fields?.ia360_cartera_state || '') !== 'esperando_tabla') return false; + await enqueueIa360Text({ record, label: 'ia360_cartera_pide_texto', body: IA360_CARTERA_COPY.pideTexto }); + return true; + } catch (err) { + console.error('[cartera] media handler error (no route):', err.message); + return false; + } +} + +// Readout al owner tras entregar el mapa (PASO 2). ownerBudget=false: un quick +// win entregado siempre se reporta. +function buildCarteraOwnerReadout({ record, contactName, deal, mapa, rows }) { + return [ + `IA360 · Quick win cartera — ${contactName || 'contacto'} (${maskIa360Number(record.contact_number)})`, + '', + `El contacto entregó su tabla de cartera (${rows.length} ${rows.length === 1 ? 'cuenta' : 'cuentas'}) y le devolví el mapa estructurado.`, + `- Cuentas con descuadre: ${mapa.cuentasConDescuadre} · Diferencia acumulada: ${carteraFormatoMonto(mapa.diferenciaTotal) || 'por calcular'}`, + deal ? `- Deal: «${deal.title || 'sin título'}» → ${deal.stage} (P7 Champions).` : '- Deal: no se encontró deal en P7 (revisar).', + '- Mapa encolado a ia360_docs_sync (destino AlekContenido).', + '', + 'No envié pitch ni agenda; el flujo quedó en modo quick win.', + ].join('\n'); +} + +// PASO 1 + PASO 2 — texto libre. Va DESPUÉS de Revenue OS y ANTES del agente +// genérico en el dispatch; devuelve true para CORTAR el embudo (guardrail: el +// agente no debe responder encima ni empujar agenda). +async function handleCarteraFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || !ia360IsClienteActivoCartera(contact)) return false; + const state = contact.custom_fields?.ia360_cartera_state || ''; + + // PASO 2 — esperando la tabla. + if (state === 'esperando_tabla') { + const rows = parseCarteraTabla(body); + if (rows.length > 0) { + const mapa = buildCarteraMapa(rows); + const deal = await syncCarteraChampionsDeal({ + record, + targetStageName: CARTERA_STAGE_QUICKWIN, + notes: `PASO 2 mapa de cartera: tabla recibida (${rows.length} cuentas). Quick win entregado.\nTabla original:\n${body}\n\n${mapa.texto}`, + }).catch(e => { console.error('[cartera] deal quick win:', e.message); return null; }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['cartera-quickwin-entregado'], + customFields: { + ia360_cartera_state: 'mapa_entregado', + ia360_cartera_mapa_at: new Date().toISOString(), + ia360_cartera_cuentas: rows.length, + ia360_cartera_tabla_raw: body, + }, + }).catch(e => console.error('[cartera] estado mapa_entregado:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_cartera_mapa', body: mapa.texto }); + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) + VALUES (NULL, $1, $2, 'AlekContenido')`, + [ + `Mapa de cartera — ${contact.name || record.contact_number} (${new Date().toISOString().slice(0, 10)})`, + `${mapa.texto}\n\n---\nTabla original pegada por el contacto:\n${body}`, + ] + ).catch(e => console.error('[cartera] docs_sync:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_cartera_readout', + body: buildCarteraOwnerReadout({ record, contactName: contact.name, deal, mapa, rows }), + targetContact: record.contact_number, + }).catch(e => console.error('[cartera] owner readout:', e.message)); + return true; + } + // Sin tabla todavía: si insiste en el tema, recordamos el formato; si + // habla de otra cosa, el agente genérico responde (respuesta siempre útil). + if (ia360EsMensajeCartera(body)) { + await enqueueIa360Text({ record, label: 'ia360_cartera_formato', body: IA360_CARTERA_COPY.recordatorioFormato }); + return true; + } + return false; + } + + // PASO 1 — disparo del flujo (solo tema cartera; gate del goal). + if (state !== 'mapa_entregado' && ia360EsMensajeCartera(body)) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['cartera-quickwin'], + customFields: { + ia360_cartera_state: 'esperando_tabla', + ia360_cartera_dolor: body, + ia360_cartera_paso1_at: new Date().toISOString(), + }, + }); + await syncCarteraChampionsDeal({ + record, + targetStageName: CARTERA_STAGE_VALIDACION, + notes: `PASO 1 mapa de cartera: el contacto reportó saldos que no cuadran. Mensaje: ${body}`, + }).catch(e => console.error('[cartera] deal paso 1:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_cartera_paso1', body: IA360_CARTERA_COPY.paso1 }); + return true; + } + return false; + } catch (err) { + console.error('[cartera] free-text handler error (no route):', err.message); + return false; + } +} + async function resolveIa360Outbound(record, dedupSuffix = '') { // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. @@ -7249,6 +7602,13 @@ router.post('/webhook/whatsapp', async (req, res) => { continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal } + // G-WIN cartera: el bot no lee imágenes. Si el contacto está a media + // captura de tabla (esperando_tabla) y manda foto/archivo, pedimos la + // versión en texto y no seguimos el embudo para este record. + if (await handleCarteraMediaInbound(record)) { + continue; + } + const { rows: pausedRows } = await pool.query( `SELECT id FROM coexistence.automation_executions WHERE wa_number=$1 AND contact_number=$2 @@ -7273,8 +7633,14 @@ router.post('/webhook/whatsapp', async (req, res) => { // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // G-WIN "Mapa de cartera" (P7 Champions) — mismo contrato que Revenue OS: + // gateado por persona+tema+estado; si actúa, CORTA el embudo para que el + // agente genérico no responda encima (guardrail: sin pitch, sin agenda). + const carteraHandled = revHandled + ? false + : await handleCarteraFreeText(record).catch(e => { console.error('[cartera] dispatch:', e.message); return false; }); // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). - if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + if (!revHandled && !carteraHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); await evaluateTriggers(record); } catch (triggerErr) { console.error('[webhook] Trigger evaluation error:', triggerErr.message); @@ -7434,6 +7800,65 @@ router.post('/internal/ia360-revenue/opener', async (req, res) => { } }); +// G-WIN cartera — vista previa del flujo completo al WhatsApp del OWNER (nunca +// a un contacto): los mensajes de los 3 pasos con datos de ejemplo, en UN solo +// texto, para aprobación de copy. Egress único: sendIa360DirectText → +// messageSender. Auth = X-IA360-Directive-Secret (patrón de los endpoints +// internos). Idempotencia: el caller decide cuándo (una sola vez por sesión). +router.post('/internal/ia360-cartera/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const ejemploRows = [ + { cuenta: 'Transportes del Bajío', saldo_portal: '$1,250,000.00', saldo_correcto: '$980,000.00', fecha_corte: '31/05/2026', responsable: 'Laura' }, + { cuenta: 'Logística Occidente', saldo_portal: '$430,500.00', saldo_correcto: '$512,300.00', fecha_corte: '31/05/2026', responsable: 'Marco' }, + ]; + const mapaEjemplo = buildCarteraMapa(ejemploRows); + const preview = [ + 'IA360 · PREVIEW flujo "Mapa de cartera" (P7 Champions) — para tu aprobación. Nada de esto se envió a Andrés.', + '', + '── PASO 1 · El contacto reporta saldos que no cuadran. El bot responde: ──', + '', + IA360_CARTERA_COPY.paso1, + '', + '── Si manda foto o archivo en lugar de texto: ──', + '', + IA360_CARTERA_COPY.pideTexto, + '', + '── PASO 2 · El contacto pega la tabla. El bot responde (datos de ejemplo): ──', + '', + mapaEjemplo.texto, + '', + '── PASO 3 · Tú recibes este readout y el deal avanza a "Quick win entregado": ──', + '', + 'IA360 · Quick win cartera — (contacto) (521***XX)', + '', + 'El contacto entregó su tabla de cartera (2 cuentas) y le devolví el mapa estructurado.', + `- Cuentas con descuadre: ${mapaEjemplo.cuentasConDescuadre} · Diferencia acumulada: ${carteraFormatoMonto(mapaEjemplo.diferenciaTotal)}`, + '- Deal: «IA360 · (contacto) · Quick win cartera» → Quick win entregado (P7 Champions).', + '- Mapa encolado a ia360_docs_sync (destino AlekContenido).', + ].join('\n'); + const record = { + wa_number: normalizePhone(req.body?.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'), + contact_number: IA360_OWNER_NUMBER, + message_id: `cartera_preview:${Date.now()}`, + message_type: 'cartera_preview', + message_body: '', + }; + const sent = await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_cartera_preview_owner', + body: preview, + }); + return res.status(sent ? 200 : 502).json({ ok: !!sent, schema: 'ia360_cartera_preview.v1', chars: preview.length }); + } catch (err) { + console.error('[cartera] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + // OPENERS V2 — vista previa de un opener al WhatsApp del OWNER (nunca a un // contacto). Renderiza el draft v2 (primer nombre + quien_intro opcional) y el // interactive (botones/lista) tal como lo vería el contacto. Único egress: diff --git a/scripts/qa-cartera-quickwin.sh b/scripts/qa-cartera-quickwin.sh new file mode 100755 index 0000000..fc5ee48 --- /dev/null +++ b/scripts/qa-cartera-quickwin.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# ============================================================================ +# qa-cartera-quickwin.sh — E2E del quick-win "Mapa de cartera" (G-WIN, P7). +# +# route_type: forgechat_monolith_e2e (starter real: inbound HMAC al webhook). +# Solo números QA 52199900*. El bot 5213321594582 nunca es contacto. Cero +# egress a contactos reales (Andrés 5213321060293 queda intacto; se audita). +# +# Escenario (contacto QA 5219990000808, persona cliente_activo/CFO): +# T1 gate tema: mensaje no-cartera NO dispara el flujo. +# T2 PASO 1: "saldos no cuadran" → Hallazgo/Impacto/Dato faltante/ +# Siguiente acción; estado esperando_tabla; nota en deal. +# T3 media: imagen durante esperando_tabla → pide versión en texto. +# T4 PASO 2: tabla pegada → mapa + deal a "Quick win entregado" + +# ia360_docs_sync + readout al owner. +# T5 gate persona: contacto NO cliente_activo (QA Aliado Uno) con texto de +# cartera NO dispara el flujo. +# T6 cero egress: 0 salientes a Andrés real desde el deploy. +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +PID_NUM="873315362541590" +QA="5219990000808" # QA CFO Cartera (cliente_activo) +QA_NEG="5219990000801" # QA Aliado Uno (aliado_socio — NO cliente) +ANDRES="5213321060293" # contacto REAL: debe quedar en cero egress +OWNER="5213322638033" +DEPLOY_TS="${DEPLOY_TS:-2026-06-10T23:54:00Z}" +DB="forgecrm-db" +BE="forgecrm-backend" +REPO="/home/alek/stack/forgechat-poc" +ENVF="$REPO/backend/.env" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +[ -n "$APP_SECRET" ] || { echo "ABORT: META_APP_SECRET vacío" >&2; exit 2; } +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" +TEST_RUN="qa-cartera.$(date +%s)" +PASS=0; FAIL=0 + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} +ts(){ date +%s; } +wamid(){ echo "wamid.${TEST_RUN}.$1.$(ts).$RANDOM"; } +_envelope(){ + printf '{"object":"whatsapp_business_account","entry":[{"id":"qa-harness","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"%s","phone_number_id":"%s"},"contacts":[{"wa_id":"%s","profile":{"name":"%s"}}],"messages":[%s]}}]}]}' "$WA" "$PID_NUM" "$1" "$2" "$3" +} +inject_text(){ # $1=from $2=texto(JSON-escapado, \n permitido) $3=profile + post_webhook "$(_envelope "$1" "${3:-QA Harness}" "{\"from\":\"$1\",\"id\":\"$(wamid txt)\",\"timestamp\":\"$(ts)\",\"type\":\"text\",\"text\":{\"body\":\"$2\"}}")" +} +inject_image(){ # $1=from $2=profile + post_webhook "$(_envelope "$1" "${2:-QA Harness}" "{\"from\":\"$1\",\"id\":\"$(wamid img)\",\"timestamp\":\"$(ts)\",\"type\":\"image\",\"image\":{\"id\":\"qa-fake-media-$(ts)\",\"mime_type\":\"image/jpeg\",\"sha256\":\"qa\"}}")" +} +max_out_id(){ psql_q "SELECT COALESCE(MAX(id),0) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1'"; } +fresh_label_body(){ psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' AND id>$3 AND template_meta->>'label'='$2' ORDER BY id DESC LIMIT 1"; } +fresh_cartera_count(){ psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' AND id>$2 AND template_meta->>'label' LIKE 'ia360_cartera%'"; } +cartera_state(){ psql_q "SELECT COALESCE(custom_fields->>'ia360_cartera_state','') FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$1'"; } +deal_stage(){ psql_q "SELECT s.name FROM coexistence.deals d JOIN coexistence.pipeline_stages s ON s.id=d.stage_id WHERE d.pipeline_id=7 AND d.contact_number='$1' ORDER BY d.updated_at DESC NULLS LAST, d.id DESC LIMIT 1"; } +chk(){ # $1=nombre $2=obtenido $3=esperado + if [ "$2" = "$3" ]; then echo "PASS $1 = '$2'"; PASS=$((PASS+1)); + else echo "FAIL $1: esperado '$3', obtenido '$2'"; FAIL=$((FAIL+1)); fi +} +chk_contains(){ # $1=nombre $2=haystack $3=needle + if printf '%s' "$2" | grep -qF "$3"; then echo "PASS $1 contiene '$3'"; PASS=$((PASS+1)); + else echo "FAIL $1 NO contiene '$3'"; FAIL=$((FAIL+1)); fi +} + +echo "== Setup: contacto QA CFO Cartera ($QA) + deal P7 en 'Validación en curso' ==" +psql_q "INSERT INTO coexistence.contacts (wa_number, contact_number, name, tags, custom_fields, created_at, updated_at) + VALUES ('$WA','$QA','QA CFO Cartera','[\"persona:cliente_activo\",\"staged\",\"qa-cartera\"]'::jsonb,'{\"persona_context\":\"Cliente activo\",\"staged\":true}'::jsonb,NOW(),NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name='QA CFO Cartera', + tags=(SELECT COALESCE(jsonb_agg(DISTINCT v),'[]'::jsonb) FROM jsonb_array_elements_text(COALESCE(coexistence.contacts.tags,'[]'::jsonb) || EXCLUDED.tags) v), + custom_fields=COALESCE(coexistence.contacts.custom_fields,'{}'::jsonb) || EXCLUDED.custom_fields, + updated_at=NOW()" +psql_q "INSERT INTO coexistence.deals (pipeline_id, stage_id, title, value, currency, status, contact_wa_number, contact_number, contact_name, notes, position, created_by, assigned_user_id) + SELECT 7, 54, 'IA360 · QA CFO Cartera · Quick win cartera', 0, 'MXN', 'open', '$WA', '$QA', 'QA CFO Cartera', + '[setup QA] deal staged para E2E G-WIN mapa de cartera', COALESCE((SELECT MAX(position)+1 FROM coexistence.deals WHERE stage_id=54),0), + (SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1), + (SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1) + WHERE NOT EXISTS (SELECT 1 FROM coexistence.deals WHERE pipeline_id=7 AND contact_number='$QA')" +# Estado limpio del flujo para corrida repetible +psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields - 'ia360_cartera_state' - 'ia360_cartera_dolor' - 'ia360_cartera_tabla_raw' WHERE wa_number='$WA' AND contact_number='$QA'" +psql_q "UPDATE coexistence.deals SET stage_id=54, status='open' WHERE pipeline_id=7 AND contact_number='$QA'" +echo "deal QA: $(deal_stage "$QA")" + +echo "" +echo "== T1 · GATE TEMA: mensaje no-cartera NO dispara el flujo ==" +B1=$(max_out_id "$QA") +chk "T1 HTTP" "$(inject_text "$QA" "Hola, ¿me recomiendas un libro de liderazgo para mi equipo de finanzas?" "QA CFO Cartera")" "200" +sleep 4 +chk "T1 salientes ia360_cartera*" "$(fresh_cartera_count "$QA" "$B1")" "0" +chk "T1 estado cartera" "$(cartera_state "$QA")" "" + +echo "" +echo "== T2 · PASO 1: saldos que no cuadran → Hallazgo/Impacto/Dato faltante/Siguiente acción ==" +B2=$(max_out_id "$QA") +chk "T2 HTTP" "$(inject_text "$QA" "Oye, los saldos de cartera que muestra el portal no cuadran con lo que tenemos en contabilidad. Traigo varias cuentas mal." "QA CFO Cartera")" "200" +sleep 4 +P1=$(fresh_label_body "$QA" "ia360_cartera_paso1" "$B2") +chk_contains "T2 respuesta" "$P1" "*Hallazgo:*" +chk_contains "T2 respuesta" "$P1" "*Impacto:*" +chk_contains "T2 respuesta" "$P1" "*Dato faltante:*" +chk_contains "T2 respuesta" "$P1" "*Siguiente acción:*" +chk_contains "T2 respuesta pide tabla" "$P1" "Cliente | Saldo en portal | Saldo correcto | Fecha de corte | Responsable" +chk_contains "T2 respuesta avisa imágenes" "$P1" "no puedo leer imágenes" +case "$P1" in *agenda*|*llamada*|*horario*) echo "FAIL T2 contiene agenda/pitch"; FAIL=$((FAIL+1));; *) echo "PASS T2 sin agenda ni pitch"; PASS=$((PASS+1));; esac +chk "T2 estado" "$(cartera_state "$QA")" "esperando_tabla" + +echo "" +echo "== T3 · MEDIA: imagen durante esperando_tabla → pide versión en texto ==" +B3=$(max_out_id "$QA") +chk "T3 HTTP" "$(inject_image "$QA" "QA CFO Cartera")" "200" +sleep 4 +P3=$(fresh_label_body "$QA" "ia360_cartera_pide_texto" "$B3") +chk_contains "T3 respuesta" "$P3" "no puedo leer imágenes ni documentos" +chk "T3 estado sigue" "$(cartera_state "$QA")" "esperando_tabla" + +echo "" +echo "== T4 · PASO 2: tabla pegada → mapa + deal + docs_sync + readout owner ==" +B4=$(max_out_id "$QA") +BO=$(max_out_id "$OWNER") +DS=$(psql_q "SELECT COALESCE(MAX(id),0) FROM coexistence.ia360_docs_sync") +TABLA='Cliente | Saldo en portal | Saldo correcto | Fecha de corte | Responsable\nTransportes del Bajío | 1,250,000.00 | 980,000.00 | 31/05/2026 | Laura\nLogística Occidente | 430,500.00 | 512,300.00 | 31/05/2026 | Marco\nGrúas y Plataformas GDL | 88,000.00 | 88,000.00 | 31/05/2026 | Laura' +chk "T4 HTTP" "$(inject_text "$QA" "$TABLA" "QA CFO Cartera")" "200" +sleep 5 +P4=$(fresh_label_body "$QA" "ia360_cartera_mapa" "$B4") +chk_contains "T4 mapa" "$P4" "*Mapa de cartera — saldos por corregir*" +chk_contains "T4 mapa cuenta 1" "$P4" "Cuenta: Transportes del Bajío" +chk_contains "T4 mapa diferencia 1" "$P4" "Diferencia: -\$270,000.00" +chk_contains "T4 mapa responsable" "$P4" "confirmarlo con Laura" +chk_contains "T4 mapa resumen" "$P4" "Cuentas con descuadre: 2 de 3" +chk "T4 estado" "$(cartera_state "$QA")" "mapa_entregado" +chk "T4 deal stage" "$(deal_stage "$QA")" "Quick win entregado" +DSROW=$(psql_q "SELECT titulo FROM coexistence.ia360_docs_sync WHERE id>$DS AND destino='AlekContenido' ORDER BY id DESC LIMIT 1") +chk_contains "T4 docs_sync" "$DSROW" "Mapa de cartera — QA CFO Cartera" +RO=$(fresh_label_body "$OWNER" "owner_cartera_readout" "$BO") +chk_contains "T4 readout owner" "$RO" "Quick win cartera — QA CFO Cartera" +chk_contains "T4 readout owner deal" "$RO" "Quick win entregado" + +echo "" +echo "== T5 · GATE PERSONA: aliado (NO cliente) con texto de cartera NO dispara ==" +B5=$(max_out_id "$QA_NEG") +chk "T5 HTTP" "$(inject_text "$QA_NEG" "Los saldos de la cartera del portal no cuadran, hay diferencias." "QA Aliado Uno")" "200" +sleep 4 +chk "T5 salientes ia360_cartera*" "$(fresh_cartera_count "$QA_NEG" "$B5")" "0" +chk "T5 estado cartera" "$(cartera_state "$QA_NEG")" "" + +echo "" +echo "== T6 · CERO EGRESS a Andrés real ($ANDRES) desde el deploy ==" +chk "T6 salientes a Andrés" "$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$ANDRES' AND created_at >= '$DEPLOY_TS'")" "0" +echo "Deals Nexus P7 (16-24) — deben seguir en Validación en curso:" +psql_q "SELECT d.id || ' | ' || s.name || ' | updated=' || d.updated_at FROM coexistence.deals d JOIN coexistence.pipeline_stages s ON s.id=d.stage_id WHERE d.pipeline_id=7 AND d.id BETWEEN 16 AND 24 ORDER BY d.id" + +echo "" +echo "==============================================================" +echo "RESULTADO: PASS=$PASS FAIL=$FAIL test_run=$TEST_RUN" +echo "==============================================================" +[ "$FAIL" -eq 0 ] From fbe4e38b7d86554c0e6599caf281ff4fac9e38e6 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Thu, 11 Jun 2026 01:25:47 +0000 Subject: [PATCH 26/39] =?UTF-8?q?feat(ia360):=20G-COLD=20=E2=80=94=20cold?= =?UTF-8?q?=20send=20para=20las=2024=20secuencias=20persona-first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metaTemplateName mapeado en las 24 secuencias del catálogo (44-50 APPROVED previos + 17 nuevos ids 51-67, todos APPROVED por Meta el mismo día) - UX pre-aprobación fuera de ventana 24h: selector marca disponibilidad real por secuencia, readout avisa antes de aprobar, tarjeta sin fila Aprobar cuando no puede salir - Cinturón en approve-send: re-verifica status APPROVED antes de encolar (tarjetas viejas/races) con diagnóstico honesto - enqueueIa360Template: vars para {{2}} (quien_intro sanitizado) y allowTextFallback=false en frío (sin éxitos falsos) - resolveIa360TemplateButtonAlias: match por título antes del gate semántico (botones de los 17 templates rutean) - Harness gcold-e2e.sh (5 sims) + gcold-simc/simd standalone + gcold-templates.js (submit idempotente) E2E: 54/54 efectivos (harness completo con presupuesto owner 6/60s) + regresión post-review SIM C 13/13 y SIM D 15/15; test-alias 8/8 Co-Authored-By: Claude Fable 5 --- backend/src/routes/webhook.js | 305 +++++++++++++++++++++++++++++----- gcold-e2e.sh | 259 +++++++++++++++++++++++++++++ gcold-simc.sh | 159 ++++++++++++++++++ gcold-simd.sh | 147 ++++++++++++++++ gcold-templates.js | 153 +++++++++++++++++ 5 files changed, 980 insertions(+), 43 deletions(-) create mode 100644 gcold-e2e.sh create mode 100644 gcold-simc.sh create mode 100644 gcold-simd.sh create mode 100644 gcold-templates.js diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 9a49218..3513cd7 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -1934,7 +1934,7 @@ function renderIa360TemplateBody(tpl, record) { k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); } -async function buildIa360TemplateComponents(tpl, account, record) { +async function buildIa360TemplateComponents(tpl, account, record, vars = null) { const components = []; const headerType = String(tpl.header_type || 'NONE').toUpperCase(); const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); @@ -1948,10 +1948,15 @@ async function buildIa360TemplateComponents(tpl, account, record) { if (indexes.length) { components.push({ type: 'body', - parameters: indexes.map(k => ({ - type: 'text', - text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), - })), + // G-COLD: para índices distintos de '1', vars (valor real del flujo, p.ej. + // quien_intro) tiene prioridad SOLO si trae contenido; si no, se conserva + // el fallback samples[k] || ' ' de los flujos existentes. + parameters: indexes.map(k => { + if (k === '1') return { type: 'text', text: firstNameForTemplate(record) }; + const v = vars?.[k]; + const hasVar = v != null && String(v).trim() !== ''; + return { type: 'text', text: hasVar ? String(v) : String(samples[k] || ' ') }; + }), }); } return components; @@ -1977,7 +1982,7 @@ async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { return last || { status: 'unknown' }; } -async function enqueueIa360Template({ record, label, templateName, templateId = null }) { +async function enqueueIa360Template({ record, label, templateName, templateId = null, vars = null, allowTextFallback = true }) { const resolved = await resolveIa360Outbound(record, `:${label}`); if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; const { account } = resolved; @@ -2009,7 +2014,7 @@ async function enqueueIa360Template({ record, label, templateName, templateId = } let components; try { - components = await buildIa360TemplateComponents(tpl, account, record); + components = await buildIa360TemplateComponents(tpl, account, record, vars); } catch (err) { console.error('[ia360-owner-pipe] template components error:', err.message); return { ok: false, status: 'template_components_error', error: err.message }; @@ -2024,6 +2029,13 @@ async function enqueueIa360Template({ record, label, templateName, templateId = try { const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); if (!v.valid) { + // G-COLD: fuera de ventana el fallback a texto libre está PROHIBIDO + // (allowTextFallback:false): Meta lo rechazaría y aquí se reportaría un + // éxito falso; además renderiza con samples, no con vars reales. + if (!allowTextFallback) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> sin fallback (allowTextFallback=false)`); + return { ok: false, status: 'template_invalid', error: v.errors.join('; ') }; + } console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; @@ -2477,6 +2489,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { si_pregunta: 'Va la pregunta: si este mensaje te hubiera llegado sin conocer a Alek, ¿se entiende qué es IA360 y qué puedo y no puedo hacer como IA, o hay algo que te haría desconfiar? Dímelo con toda franqueza; para eso es esta prueba.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, + metaTemplateName: 'ia360_beta_architectura', // G-COLD: template frío con los mismos botones del opener openerOptions: { kind: 'buttons', options: [ @@ -2498,6 +2511,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { si_pregunta: 'Gracias. ¿Cómo se siente recibir un mensaje así de una IA: natural, raro o invasivo? Lo que me digas se lo paso a Alek tal cual, sin suavizarlo.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, + metaTemplateName: 'ia360_beta_feedback', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2519,6 +2533,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { si_a_ver: 'Va. Pregúntame algo que Alek y tú hayan platicado o trabajado antes, y te digo qué tengo registrado. Tú pones la prueba.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, + metaTemplateName: 'ia360_beta_memoria', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2549,6 +2564,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { pregunta: '¿Qué te contó la persona que nos presentó sobre lo que hace Alek, y qué te llamó la atención para aceptar la introducción? Con eso evitamos mandarte algo fuera de lugar.', }, draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + // G-COLD: el {{2}} del template es quien_intro; en frío se exige el dato + // antes de aprobar (ver handleIa360OwnerApproveSend). + metaTemplateName: 'ia360_referido_contexto', openerOptions: { kind: 'buttons', options: [ @@ -2570,6 +2588,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { si_cuentame: 'Va la versión completa en corto: IA360 conecta WhatsApp, CRM, agenda y memoria de clientes para que el seguimiento no dependa de la memoria de nadie. ¿En tu operación dónde se cae más el seguimiento hoy: mensajes, CRM o agenda?', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, + metaTemplateName: 'ia360_referido_oneliner', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2591,9 +2610,9 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { pregunta: 'Claro, pregunta con confianza: qué hace IA360, cómo trabaja Alek o qué implicaría la llamada. Te respondo aquí mismo.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, - metaTemplateName: 'ia360_referido_apertura', - // El template de Meta trae botones de texto ("Sí, cuéntame"); su afirmativo - // mapea a la rama de horarios (el copy pide permiso para proponer llamada). + metaTemplateName: 'ia360_referido_permiso_agenda_v2', + // G-COLD: el template v2 ya trae los mismos botones del opener; el alias + // mapea su afirmativo a la rama de horarios. templateAliasOption: 'horarios', openerOptions: { kind: 'buttons', @@ -2625,7 +2644,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { si_pregunta: 'Gracias. ¿Qué tipo de clientes atiendes hoy y dónde los ves sufrir más: WhatsApp desordenado, CRM sin seguimiento o procesos repetidos a mano? Con eso mapeamos el fit.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, - metaTemplateName: 'ia360_aliado_mapa_colaboracion', + metaTemplateName: 'ia360_aliado_mapa_colaboracion_v2', // G-COLD: v2 con botones QUICK_REPLY del opener openerOptions: { kind: 'buttons', options: [ @@ -2647,6 +2666,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { si_pregunta: 'Va: cuando un cliente tuyo ya necesita ordenar WhatsApp, CRM o seguimiento, ¿qué señales lo delatan primero? Con eso definimos juntos a quién sí presentarle IA360.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, + metaTemplateName: 'ia360_aliado_criterios_fit', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2668,6 +2688,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { si_comparte: 'Va el caso NDA-safe en corto: una empresa de servicios perdía seguimiento entre WhatsApp y su CRM; con IA360 cada conversación queda registrada, el pipeline se mueve solo y el dueño revisa su semana en un tablero. ¿Le haría sentido a alguno de tus clientes?', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, + metaTemplateName: 'ia360_aliado_caso_reventa', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2699,6 +2720,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { todo_bien: 'Qué bueno. Le paso a Alek que todo va en orden. Cualquier cosa que surja, me escribes por aquí y se lo pongo enfrente.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, + metaTemplateName: 'ia360_cliente_readout', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2721,6 +2743,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { todo_orden: 'Perfecto, me da gusto. Le confirmo a Alek que no hay pendientes de su lado. Aquí sigo si surge algo.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, + metaTemplateName: 'ia360_cliente_soporte', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2740,6 +2763,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { cta: 'identificar siguiente módulo con permiso', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te escribo de su parte porque tú y Alek ya tienen un proyecto andando, y Alek quiere ubicar dónde estaría el siguiente paso con más impacto, sin empujarte nada fuera de tiempo. De estas áreas, ¿cuál te quita más tiempo hoy?`, requiresLiveDeal: true, + metaTemplateName: 'ia360_cliente_expansion', // G-COLD: en frío sale como QUICK_REPLY (la lista vive en el flujo caliente) openerOptions: { kind: 'list', button: 'Elegir área', @@ -2771,6 +2795,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', cta: 'detectar cuello ejecutivo', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte antes de mandarte una demo genérica: prefiere ubicar primero dónde habría valor real para tu operación. De estas áreas, ¿dónde sientes el cuello de botella que más mueve la aguja?`, + metaTemplateName: 'ia360_sponsor_diagnostico', // G-COLD openerOptions: { kind: 'list', button: 'Elegir área', @@ -2792,6 +2817,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', cta: 'pedir síntoma de fuga de valor', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?`, + metaTemplateName: 'ia360_sponsor_fuga_valor', // G-COLD openerOptions: { kind: 'list', button: 'Elegir fuga', @@ -2816,6 +2842,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { si_manda: 'Va el caso en corto: una operación que dependía de WhatsApp y Excel perdía seguimiento y visibilidad; con IA360 los mensajes alimentan el CRM, el pipeline se mueve solo y la dirección revisa su semana en un tablero. Si quieres, Alek te aterriza el paralelo con tu operación en una llamada corta.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, + metaTemplateName: 'ia360_sponsor_caso_ndasafe', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2843,6 +2870,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', cta: 'ubicar fuga principal del pipeline', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja ayudando a equipos comerciales y casi siempre el problema aparece en uno de tres lugares. En tu equipo, ¿cuál duele más hoy?`, + metaTemplateName: 'ia360_comercial_pipeline', // G-COLD openerOptions: { kind: 'list', button: 'Elegir', @@ -2863,6 +2891,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', cta: 'mapear seguimiento WhatsApp/CRM', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy?`, + metaTemplateName: 'ia360_comercial_wa_crm', // G-COLD openerOptions: { kind: 'list', button: 'Elegir', @@ -2884,6 +2913,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', cta: 'detectar si hay motor comercial repetible', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?`, + metaTemplateName: 'ia360_comercial_motor_prospeccion', // G-COLD openerOptions: { kind: 'list', button: 'Elegir', @@ -2913,6 +2943,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', cta: 'detectar punto de control débil', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja con equipos de finanzas que terminan operando a mano porque no pueden confiar rápido en sus datos. En tu caso, ¿dónde está el mayor dolor hoy?`, + metaTemplateName: 'ia360_cfo_control', // G-COLD openerOptions: { kind: 'list', button: 'Elegir área', @@ -2937,6 +2968,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { respondo: 'Te leo. Cuéntame qué información te cuesta más tener confiable y a tiempo (cartera, cobranza, reportes), y se la paso a Alek aterrizada.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + metaTemplateName: 'ia360_cfo_cartera_datos', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -2955,6 +2987,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', cta: 'detectar reglas financieras propensas a error', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + metaTemplateName: 'ia360_cfo_comisiones', // G-COLD openerOptions: { kind: 'list', button: 'Elegir', @@ -2987,6 +3020,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { mapa: 'Va el mapa corto: WhatsApp Cloud API → ForgeChat (bandeja y reglas) → n8n (orquestación) → CRM y memoria por contacto. Todo con permisos mínimos, trazabilidad de cada mensaje y aprobación humana antes de cualquier envío sensible. Si quieres el detalle técnico completo, Alek te lo manda directo.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, + metaTemplateName: 'ia360_tecnico_arquitectura', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -3005,6 +3039,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', cta: 'identificar riesgo técnico principal', draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada?`, + metaTemplateName: 'ia360_tecnico_rollback', // G-COLD openerOptions: { kind: 'list', button: 'Elegir riesgo', @@ -3030,6 +3065,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { respondo: 'Te leo. Dime qué condición tendría que cumplirse para que la prueba te parezca segura (permisos, alcance, datos, reversibilidad) y la registro tal cual para Alek.', }, draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, + metaTemplateName: 'ia360_tecnico_integracion', // G-COLD openerOptions: { kind: 'buttons', options: [ @@ -3330,8 +3366,10 @@ async function handleIa360SequenceReply({ record, replyId, contact = null }) { const IA360_SEQ_ALIAS_NEGATIVE = new Set(['ahora no', 'por ahora no', 'no por ahora']); const IA360_SEQ_ALIAS_HANDOFF = new Set(['que me escriba alek', 'hablar con alek']); // Solo frases genuinamente afirmativas. Los títulos exactos del catálogo -// ("Proponme horarios", "Te respondo aquí", etc.) se resuelven por match de -// título, no por semántica: ponerlos aquí fabricaría elecciones equivocadas. +// ("Proponme horarios", "Te respondo aquí", "Hazme una pregunta", etc.) se +// resuelven ANTES por match exacto de título (paso 1 del resolver), no por +// semántica: ponerlos aquí fabricaría elecciones equivocadas. Estos sets solo +// aplican cuando el texto del botón NO coincide con ningún título del opener. const IA360_SEQ_ALIAS_AFFIRMATIVE = new Set([ 'sí, cuéntame', 'si, cuéntame', 'sí, cuentame', 'si, cuentame', 'sí, cuéntame más', 'si, cuentame mas', @@ -3347,20 +3385,24 @@ const IA360_SEQ_ALIAS_AFFIRMATIVE = new Set([ function resolveIa360TemplateButtonAlias({ replyId, contact }) { const key = String(replyId || '').trim().toLowerCase(); if (!key || key.startsWith('seq_')) return null; - const isNeg = IA360_SEQ_ALIAS_NEGATIVE.has(key); - const isHand = IA360_SEQ_ALIAS_HANDOFF.has(key); - const isAff = IA360_SEQ_ALIAS_AFFIRMATIVE.has(key); - if (!isNeg && !isHand && !isAff) return null; const pf = contact?.custom_fields?.ia360_persona_first; const seqId = pf?.sequence_candidate?.id; if (!seqId || !pf?.send?.sent_at) return null; // solo si su opener realmente salió const found = findIa360SequenceFlow(seqId); if (!found) return null; const opts = found.sequence.openerOptions?.options || []; - // 1) Match exacto por título visible del botón. + // 1) Match exacto por título visible del botón — ANTES del gate semántico: + // los botones legítimos de los templates fríos ("Hazme una pregunta", + // "Te respondo aquí", "Todo va bien", "Mándame el mapa", "Proponme + // horarios"...) rutean por su propio título aunque no estén en los sets. const byTitle = opts.find(o => String(o.title).trim().toLowerCase() === key); if (byTitle) return String(byTitle.id).toLowerCase(); - // 2) Por semántica: negativo → ahora_no; handoff → alek_directo; afirmativo → + // 2) Gate semántico, solo si no hubo match por título. + const isNeg = IA360_SEQ_ALIAS_NEGATIVE.has(key); + const isHand = IA360_SEQ_ALIAS_HANDOFF.has(key); + const isAff = IA360_SEQ_ALIAS_AFFIRMATIVE.has(key); + if (!isNeg && !isHand && !isAff) return null; + // 3) Por semántica: negativo → ahora_no; handoff → alek_directo; afirmativo → // la primera opción que no sea ninguna de las dos (el camino afirmativo). const bySuffix = (suffix) => opts.find(o => String(o.id).toLowerCase().endsWith(`:${suffix}`)); if (isNeg) { const o = bySuffix('ahora_no'); return o ? String(o.id).toLowerCase() : null; } @@ -3624,6 +3666,60 @@ async function persistIa360PersonaPayload({ record, targetContact, flow, sequenc // G-D: pipelines donde un deal vivo habilita la jugada de expansión (D7). const IA360_EXPANSION_PIPELINES = ['IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión']; +// G-COLD: status real en Meta de los templates fríos, en UNA sola consulta. +// Si un nombre tiene varias filas gana APPROVED si alguna lo está (mismo +// criterio que enqueueIa360Template); si no, la más reciente por updated_at. +// En error de DB devuelve NULL (no {}): un {} significaría "template +// inexistente" y daría diagnóstico falso. Cada call site decide: la UX sale +// sin marcas (fail-open) y el envío se bloquea con aviso honesto (fail-closed). +async function loadIa360ColdTemplateStatuses(names) { + const list = [...new Set((names || []).filter(Boolean).map(String))]; + if (!list.length) return {}; + try { + const { rows } = await pool.query( + `SELECT name, status + FROM coexistence.message_templates + WHERE name = ANY($1) + ORDER BY updated_at DESC NULLS LAST, id DESC`, + [list] + ); + const out = {}; + for (const row of rows) { + if (String(out[row.name] || '').toUpperCase() === 'APPROVED') continue; + if (String(row.status || '').toUpperCase() === 'APPROVED') { out[row.name] = row.status; continue; } + if (!(row.name in out)) out[row.name] = row.status; // primera fila = más reciente + } + return out; + } catch (e) { + console.error('[ia360-cold] template statuses lookup:', e.message); + return null; + } +} + +// G-COLD: traduce el status de Meta a disponibilidad para envío en frío. +function ia360ColdAvailability(status) { + const s = String(status || '').toUpperCase(); + if (s === 'APPROVED') return { sendable: true, label: '✓ lista para frío' }; + if (['PENDING', 'SUBMITTED', 'IN_REVIEW'].includes(s)) return { sendable: false, label: 'template en revisión Meta' }; + if (s === 'REJECTED') return { sendable: false, label: 'template rechazado por Meta' }; + return { sendable: false, label: 'sin template frío' }; +} + +// G-COLD: ¿el contacto está fuera de la ventana de servicio de 24h? Mismo +// umbral 23.5h que approve-send. Error o cuenta sin resolver → {known:false} +// (fail-open: la UX del selector nunca debe romperse por esta verificación). +async function ia360OutsideWindow24h({ record, targetContact }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) return { known: false, outside: false }; + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + return { known: true, outside: !(secs != null && secs < 23.5 * 3600) }; + } catch (e) { + console.error('[ia360-cold] window check:', e.message); + return { known: false, outside: false }; + } +} + // G-D: señales reales del contacto para el ranker del selector de secuencias. // Cada consulta tiene su propio try/catch (fail-open): si la DB falla, esa señal // queda en null y el selector sale con el orden default — nunca mudo. @@ -3820,12 +3916,48 @@ async function sendIa360SequenceSelector({ record, targetContact, contact, flowK const signals = await gatherIa360ContactSignals({ waNumber: record.wa_number, contactNumber: targetContact }); const ranking = rankIa360Sequences({ flow, signals }); const summaryLines = buildIa360ContactSummaryLines(signals); + // G-COLD: si el contacto está fuera de la ventana de 24h, cada fila antepone + // la disponibilidad real del template frío (status en coexistence.message_templates). + // Fail-open con try/catch: si algo falla, el selector sale como hoy y se loggea. + let coldInfo = null; + try { + const win = await ia360OutsideWindow24h({ record, targetContact }); + if (win.known && win.outside) { + const statuses = await loadIa360ColdTemplateStatuses( + (flow.sequences || []).map(s => s.metaTemplateName).filter(Boolean) + ); + // null = falló el lookup (≠ inexistente): selector sin marcas, como hoy. + if (statuses === null) { + console.error('[ia360-cold] selector: lookup de statuses falló; selector sin marcas frías'); + } else { + coldInfo = { outsideWindow: true, statuses }; + } + } + } catch (e) { console.error('[ia360-cold] selector availability:', e.message); } const bodyText = [ `Alek, ${name} quedó como ${flow.personaContext}.`, ...summaryLines, + ...(coldInfo ? ['Fuera de ventana de 24h: solo las secuencias marcadas «lista para frío» pueden salir hoy (como template de Meta).'] : []), 'Elige una secuencia. Sigo en dry-run: no enviaré nada al contacto.', ].join('\n'); const suggestedReason = ranking.suggestedId ? ranking.reasonFor(ranking.suggestedId) : null; + // G-COLD: las filas se calculan una sola vez — se renderizan en la tarjeta y + // se persisten tal cual en ia360_selector_ranking.rows (auditoría 1:1). + const selectorRows = ranking.ordered.map(seq => { + const reason = ranking.suggestedId === seq.id ? ranking.reasonFor(seq.id) : null; + let description; + if (coldInfo) { + const avail = ia360ColdAvailability(seq.metaTemplateName ? coldInfo.statuses[seq.metaTemplateName] : undefined); + description = compactForWhatsApp(`${avail.label} · ${reason ? `Sugerida: ${reason}` : seq.goal}`, 72); + } else { + description = compactForWhatsApp(reason ? `Sugerida: ${reason}` : seq.goal, 72); + } + return { + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description, + }; + }); // G-D: el ranking queda auditable en custom_fields (orden, sugerida, razón, // resumen) — best-effort, no bloquea el envío de la tarjeta. await mergeContactIa360State({ @@ -3840,6 +3972,21 @@ async function sendIa360SequenceSelector({ record, targetContact, contact, flowK reason: suggestedReason, order: ranking.ordered.map(seq => seq.id), summary: summaryLines, + // G-COLD: filas tal como se renderizaron en la tarjeta. + rows: selectorRows, + ...(coldInfo ? { + cold: { + outside_window: true, + availability: Object.fromEntries( + (flow.sequences || []) + .filter(seq => seq.metaTemplateName) + .map(seq => { + const status = coldInfo.statuses[seq.metaTemplateName] || null; + return [seq.id, { template: seq.metaTemplateName, status, label: ia360ColdAvailability(status).label }]; + }) + ), + }, + } : {}), }, }, }).catch(e => console.error('[ia360-rank] persist ranking:', e.message)); @@ -3864,14 +4011,8 @@ async function sendIa360SequenceSelector({ record, targetContact, contact, flowK button: 'Elegir secuencia', sections: [{ title: compactForWhatsApp(flow.personaContext, 24), - rows: ranking.ordered.map(seq => { - const reason = ranking.suggestedId === seq.id ? ranking.reasonFor(seq.id) : null; - return { - id: `owner_seq:${targetContact}:${seq.id}`, - title: compactForWhatsApp(seq.uiTitle || seq.label, 24), - description: compactForWhatsApp(reason ? `Sugerida: ${reason}` : seq.goal, 72), - }; - }), + // G-COLD: mismas filas que quedaron persistidas en el ranking. + rows: selectorRows, }], }, }, @@ -3933,12 +4074,55 @@ async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceI return; } const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); - const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + let readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); if (hasUnresolvedIa360Placeholder(readout)) { payload.sequence_candidate.copy_status = 'blocked'; payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; } await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + // G-COLD: aviso pre-aprobación. Si el contacto está fuera de la ventana de 24h, + // el owner debe saber ANTES de aprobar si el opener saldrá como template de + // Meta (mismo copy, con botones) o si no puede salir nada todavía. + // Fail-open: si la verificación falla, el readout sale como hoy. + let coldBlocked = false; + let coldNotice = null; + try { + const win = await ia360OutsideWindow24h({ record, targetContact }); + if (win.known && win.outside) { + const tplName = sequence.metaTemplateName || null; + if (!tplName) { + // Defensivo: hoy las 24 secuencias tienen template mapeado, pero si una + // nueva llega sin él, el aviso no debe imprimir «null». + coldBlocked = true; + readout += `\n\nAVISO: ${name} está fuera de la ventana de 24h y esta secuencia no tiene template frío mapeado. Si apruebas ahora NO puede salir nada.`; + coldNotice = 'Fuera de ventana de 24h y sin template frío mapeado: hoy no puede salir nada.'; + } else { + const statuses = await loadIa360ColdTemplateStatuses([tplName]); + if (statuses === null) { + // null = falló el lookup (≠ inexistente): sin aviso ni coldBlocked, + // comportamiento previo; el cinturón del approve-send re-verifica. + console.error('[ia360-cold] readout: lookup de statuses falló; readout sin aviso frío'); + } else { + const avail = ia360ColdAvailability(statuses[tplName]); + if (avail.sendable) { + readout += `\n\nFuera de ventana de 24h: si apruebas, el opener saldrá como template aprobado de Meta «${tplName}» (mismo copy, con sus botones), no como texto libre.`; + coldNotice = `Fuera de ventana de 24h: el opener saldrá como template «${tplName}».`; + } else { + coldBlocked = true; + readout += `\n\nAVISO: ${name} está fuera de la ventana de 24h y el template «${tplName}» de esta secuencia aún no está aprobado por Meta (${avail.label}). Si apruebas ahora NO puede salir nada. Opciones: espera la aprobación de Meta, elige una secuencia marcada «lista para frío» o toma el contacto manual.`; + coldNotice = `Fuera de ventana de 24h y sin template aprobado (${avail.label}): hoy no puede salir nada.`; + } + } + } + } + } catch (e) { console.error('[ia360-cold] readout availability:', e.message); } + // G-COLD: best-effort, solo auditoría/QA — el cinturón del approve-send + // re-consulta el status en vivo; nadie lee este campo en runtime. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + customFields: { ia360_approve_card_cold_blocked: coldBlocked }, + }).catch(e => console.error('[ia360-cold] persist cold_blocked:', e.message)); await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, @@ -3951,7 +4135,7 @@ async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceI // que la tarjeta de cancelación). Solo si el payload realmente requiere // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { - await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence, coldBlocked, coldNotice }); } } @@ -3969,7 +4153,20 @@ function ia360ApproveSendAllowlist() { .filter(Boolean); } -async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence, coldBlocked = false, coldNotice = null }) { + // G-COLD: con coldNotice el owner ve en la tarjeta cómo saldría el opener (o + // por qué no puede salir); con coldBlocked se RETIRA la fila "Aprobar y + // enviar" — el owner jamás debe poder aprobar algo imposible. + let bodyText = `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`; + if (coldNotice) bodyText += `\n${coldNotice}`; + if (bodyText.length > 1024) bodyText = compactForWhatsApp(bodyText, 1024); + const rows = [ + ...(coldBlocked ? [] : [{ id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }]), + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ]; return sendOwnerInteractive({ record, label: `owner_approve_card_${targetContact}_${sequence.id}`, @@ -3979,21 +4176,13 @@ async function sendIa360ApproveCard({ record, targetContact, name, flow, sequenc interactive: { type: 'list', header: { type: 'text', text: 'Aprobar envío' }, - body: { - text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, - }, + body: { text: bodyText }, footer: { text: 'Solo envío con tu aprobación explícita' }, action: { button: 'Decidir', sections: [{ title: 'Acciones', - rows: [ - { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, - { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, - { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, - { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, - { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, - ], + rows, }], }, }, @@ -4136,8 +4325,9 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } } // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere - // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); - // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template). + // G-COLD: las 24 secuencias persona-first ya tienen metaTemplateName mapeado; + // el deny outside_window_no_template queda solo como red de seguridad. const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); @@ -4173,7 +4363,36 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } const status = await waitForIa360OutboundStatus(handlerFor); sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; } else if (sequence.metaTemplateName) { - const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + // G-COLD: cinturón contra tarjetas viejas o races. La tarjeta sin la fila + // "Aprobar y enviar" protege la UX, pero un tap sobre una tarjeta emitida + // antes (o un cambio de status en Meta entre tarjeta y tap) llegaría hasta + // aquí: se consulta el status REAL del template antes de encolar nada. + const coldStatuses = await loadIa360ColdTemplateStatuses([sequence.metaTemplateName]); + if (coldStatuses === null) { + // null = falló la consulta (≠ template inexistente): fail-closed en el + // envío, pero con diagnóstico honesto para el owner. + return deny('cold_template_status_check_failed', `No pude verificar el status del template «${sequence.metaTemplateName}» en la base (error de consulta). Por seguridad no envié nada; reintenta en un momento.`); + } + const coldStatus = coldStatuses[sequence.metaTemplateName] || null; + if (String(coldStatus || '').toUpperCase() !== 'APPROVED') { + return deny('outside_window_template_not_approved', `${name} está fuera de la ventana de 24h y el template «${sequence.metaTemplateName}» de la secuencia ${sequence.id} aún no está aprobado por Meta (${coldStatus || 'inexistente'}). No envié nada.`); + } + // G-COLD: referido_contexto en frío necesita el {{2}} (quien_intro), igual + // que el draft caliente lo calcula buildIa360PersonaPayload. Sin el dato, + // el template saldría con un hueco — mejor avisar con honestidad. + // Sanitizado vía compactForWhatsApp (colapsa espacios/saltos y topa a 60): + // Meta rechaza parámetros con saltos de línea o 4+ espacios consecutivos. + let templateVars = null; + if (sequence.id === 'referido_contexto') { + const quienIntro = compactForWhatsApp(contact.custom_fields?.quien_intro || '', 60); + if (!quienIntro) { + return deny('cold_send_missing_quien_intro', `El template de ${sequence.id} necesita saber quién hizo la introducción y ${name} no tiene quien_intro registrado. Captura ese dato (o elige otra secuencia) y vuelve a intentar. No envié nada.`); + } + templateVars = { '2': quienIntro }; + } + // allowTextFallback:false — en frío un fallback a texto libre sería + // rechazado por Meta y reportaría éxito falso (ver enqueueIa360Template). + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName, vars: templateVars, allowTextFallback: false }); sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; } else { return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); diff --git a/gcold-e2e.sh b/gcold-e2e.sh new file mode 100644 index 0000000..d0077d5 --- /dev/null +++ b/gcold-e2e.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E G-COLD — cold send con templates fríos para las 24 secuencias: +# selector con disponibilidad «lista para frío» fuera de ventana, aviso en el +# readout, tarjeta sin "Aprobar y enviar" cuando no hay template aprobado, +# cinturón en approve-send y envío real como template de Meta. +# SIM A — sponsor_diagnostico (APPROVED) → sale como template. +# SIM B — cfo_control (APPROVED) → sale como template. +# SIM C — aliado_mapa_colaboracion (v2 APPROVED) → sale como template. +# SIM D — aliado_criterios_fit (template en revisión) → bloqueado de punta a punta. +# SIM JR — José Ramón (contacto REAL): solo tarjeta simulada, CERO egress a él. +# Uso: bash gcold-e2e.sh (correr en el VPS) +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="873315362541590" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado='$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "$2" "contiene:$3"; fi; } +chk_not_has(){ if echo "$2" | grep -qF "$3"; then bad "$1" "$2" "NO contiene:$3"; else ok "$1"; fi; } +chk_any(){ # $1=nombre $2=valor $3=patron grep -E (alternativas) + if echo "$2" | grep -qE "$3"; then ok "$1"; else bad "$1" "$2" "matchea:$3"; fi +} + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } +WAMID_BASE="wamid.e2e.gcold.$(ts)" + +owner_msg_id_by_label(){ # $1=label → message_id de la última saliente al owner con ese label + psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} +owner_body_by_label(){ + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} + +# G-COLD: espera con polling a que aparezca la saliente al owner con ese label. +# El presupuesto owner es 6 mensajes/60s: el polling absorbe la latencia de la +# cola sin depender de sleeps exactos. +wait_owner_msg(){ # $1=label $2=timeout_s (default 90) -> imprime message_id + local deadline=$(( $(date +%s) + ${2:-90} )) + local mid="" + while [ "$(date +%s)" -lt "$deadline" ]; do + mid=$(owner_msg_id_by_label "$1") + [ -n "$mid" ] && { printf '%s' "$mid"; return 0; } + sleep 5 + done + printf '%s' "" +} + +inject_interactive(){ # $1=reply_id $2=context_msg_id $3=title + local body + body=$(cat </dev/null + psql_q "DELETE FROM coexistence.chat_history WHERE contact_number='$1'" >/dev/null + psql_q "DELETE FROM coexistence.contacts WHERE contact_number='$1'" >/dev/null +} + +# ──────────────────────────────────────────────────────────────────────────── +# run_cold_sim — flujo feliz fuera de ventana: vCard → persona → selector con +# marcas frías → secuencia (readout con aviso de template) → tarjeta → aprobar +# → el opener sale como TEMPLATE de Meta. +# $1=num $2=vcard_name $3=persona_id $4=persona_title $5=seq_id $6=seq_title $7=template_name +# ──────────────────────────────────────────────────────────────────────────── +run_cold_sim(){ + local NUM="$1" VNAME="$2" PERSONA="$3" PTITLE="$4" SEQ="$5" STITLE="$6" TPL="$7" + + echo "--- STEP 0 — limpieza estado QA $NUM ---" + clean_qa_number "$NUM" + ok "estado limpio ($NUM)" + + echo "--- STEP 1 — owner comparte vCard \"$VNAME\" ---" + local ST; ST=$(inject_vcard "$NUM" "$VNAME" "QA" "vcard.$NUM") + chk "webhook vCard HTTP" "$ST" "200" + + local CARD1; CARD1=$(wait_owner_msg "owner_vcard_captured_$NUM" 90) + [ -n "$CARD1" ] && ok "tarjeta de captura (msg_id=$CARD1)" || bad "tarjeta captura" "" "message_id" + + echo "--- STEP 2 — owner elige persona: $PTITLE ---" + ST=$(inject_interactive "owner_pipe:$NUM:$PERSONA" "$CARD1" "$PTITLE") + chk "webhook persona HTTP" "$ST" "200" + sleep 8 + local CARD2; CARD2=$(wait_owner_msg "owner_sequence_selector_${NUM}_${PERSONA}" 90) + [ -n "$CARD2" ] && ok "selector de secuencias (msg_id=$CARD2)" || bad "selector secuencias" "" "message_id" + + echo "--- STEP 3 — asserts del ranking persistido (modo frío) ---" + local OUTW; OUTW=$(psql_q "SELECT custom_fields->'ia360_selector_ranking'->'cold'->>'outside_window' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$NUM'") + chk "cold.outside_window persistido" "$OUTW" "true" + local AVLBL; AVLBL=$(psql_q "SELECT custom_fields->'ia360_selector_ranking'->'cold'->'availability'->'$SEQ'->>'label' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$NUM'") + chk "availability($SEQ) = lista para frío" "$AVLBL" "✓ lista para frío" + + echo "--- STEP 4 — owner elige secuencia: $STITLE ---" + ST=$(inject_interactive "owner_seq:$NUM:$SEQ" "$CARD2" "$STITLE") + chk "webhook secuencia HTTP" "$ST" "200" + sleep 7 + local READOUT; READOUT=$(owner_body_by_label "owner_sequence_readout_$SEQ") + chk_has "readout avisa salida como template" "$READOUT" "saldrá como template aprobado" + local CARD3; CARD3=$(wait_owner_msg "owner_approve_card_${NUM}_${SEQ}" 90) + [ -n "$CARD3" ] && ok "tarjeta de aprobación (msg_id=$CARD3)" || bad "tarjeta aprobacion" "" "message_id" + + echo "--- STEP 5 — owner tap: Aprobar y enviar ---" + ST=$(inject_interactive "owner_approve_send:$NUM:$SEQ" "$CARD3" "Aprobar y enviar") + chk "webhook aprobar HTTP" "$ST" "200" + sleep 10 + + echo "--- STEP 6 — el opener salió como TEMPLATE de Meta ---" + local TROW; TROW=$(psql_q "SELECT template_meta->>'template_name' || '|' || status || '|' || COALESCE(error_message,'-') FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$NUM' AND message_type='template' ORDER BY id DESC LIMIT 1") + local TNAME TSTATUS TERR + TNAME=$(echo "$TROW" | cut -d'|' -f1) + TSTATUS=$(echo "$TROW" | cut -d'|' -f2) + TERR=$(echo "$TROW" | cut -d'|' -f3-) + echo " [TEMPLATE] name=$TNAME status=$TSTATUS error=$TERR" + chk "template_name correcto" "$TNAME" "$TPL" + chk_any "status del template en sent|failed" "$TSTATUS" "^(sent|failed)$" +} + +# ============================================================================ +echo "=== SIM A — sponsor (5219990077701): sponsor_diagnostico como template frío ===" +run_cold_sim "5219990077701" "QA Sponsor GCold" "persona_sponsor" "Sponsor / ejecutivo" "sponsor_diagnostico" "Diagnóstico ejecutivo" "ia360_sponsor_diagnostico" + +sleep 61 # G-COLD: ventana nueva del presupuesto owner (6 msg/60s) +echo "" +echo "=== SIM B — cfo (5219990077702): cfo_control como template frío ===" +run_cold_sim "5219990077702" "QA CFO GCold" "persona_cfo" "CFO / finanzas" "cfo_control" "Auditar control" "ia360_cfo_control" + +sleep 61 # G-COLD: ventana nueva del presupuesto owner (6 msg/60s) +echo "" +echo "=== SIM C — aliado (5219990077703): aliado_mapa_colaboracion v2 como template frío ===" +run_cold_sim "5219990077703" "QA Aliado GCold" "persona_aliado" "Aliado / socio" "aliado_mapa_colaboracion" "Mapa colaboración" "ia360_aliado_mapa_colaboracion_v2" + +# ============================================================================ +sleep 61 # G-COLD: ventana nueva del presupuesto owner (6 msg/60s) +echo "" +echo "=== SIM D — aliado (5219990077704): template EN REVISIÓN bloquea de punta a punta ===" +NUMD="5219990077704" + +echo "--- STEP 0 — limpieza estado QA $NUMD ---" +clean_qa_number "$NUMD" +ok "estado limpio ($NUMD)" + +echo "--- STEP 1 — vCard + persona aliado ---" +ST=$(inject_vcard "$NUMD" "QA Aliado Pending GCold" "QA" "vcard.$NUMD") +chk "webhook vCard HTTP" "$ST" "200" +sleep 6 +CARD1=$(wait_owner_msg "owner_vcard_captured_$NUMD" 90) +[ -n "$CARD1" ] && ok "tarjeta de captura (msg_id=$CARD1)" || bad "tarjeta captura" "" "message_id" +ST=$(inject_interactive "owner_pipe:$NUMD:persona_aliado" "$CARD1" "Aliado / socio") +chk "webhook persona HTTP" "$ST" "200" +sleep 7 +CARD2=$(wait_owner_msg "owner_sequence_selector_${NUMD}_persona_aliado" 90) +[ -n "$CARD2" ] && ok "selector de secuencias (msg_id=$CARD2)" || bad "selector secuencias" "" "message_id" + +echo "--- STEP 2 — availability marca el template en revisión ---" +AVLBL=$(psql_q "SELECT custom_fields->'ia360_selector_ranking'->'cold'->'availability'->'aliado_criterios_fit'->>'label' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$NUMD'") +chk "availability(aliado_criterios_fit) en revisión" "$AVLBL" "template en revisión Meta" + +echo "--- STEP 3 — owner elige secuencia: Criterios de fit ---" +ST=$(inject_interactive "owner_seq:$NUMD:aliado_criterios_fit" "$CARD2" "Criterios de fit") +chk "webhook secuencia HTTP" "$ST" "200" +sleep 7 +READOUT=$(owner_body_by_label "owner_sequence_readout_aliado_criterios_fit") +chk_has "readout trae AVISO" "$READOUT" "AVISO" +chk_has "readout dice no aprobado por Meta" "$READOUT" "no está aprobado por Meta" +COLDBLK=$(psql_q "SELECT custom_fields->>'ia360_approve_card_cold_blocked' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$NUMD'") +chk "ia360_approve_card_cold_blocked = true" "$COLDBLK" "true" +CARD3=$(wait_owner_msg "owner_approve_card_${NUMD}_aliado_criterios_fit" 90) +[ -n "$CARD3" ] && ok "tarjeta de aprobación (msg_id=$CARD3)" || bad "tarjeta aprobacion" "" "message_id" +CARD3_BODY=$(psql_q "SELECT raw_payload::text FROM coexistence.chat_history WHERE message_id='$CARD3' LIMIT 1") +chk_not_has "tarjeta SIN fila Aprobar y enviar" "$CARD3_BODY" "owner_approve_send:$NUMD:aliado_criterios_fit" + +echo "--- STEP 4 — tap de tarjeta vieja: owner_approve_send debe bloquearse ---" +ST=$(inject_interactive "owner_approve_send:$NUMD:aliado_criterios_fit" "$CARD3" "Aprobar y enviar") +chk "webhook aprobar HTTP" "$ST" "200" +sleep 8 +BLOCKED=$(owner_body_by_label "owner_approve_send_blocked") +chk_has "cinturón avisa template no aprobado" "$BLOCKED" "aún no está aprobado por Meta" +TCOUNT=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$NUMD' AND message_type='template'") +chk "cero templates salientes al QA" "$TCOUNT" "0" + +# ============================================================================ +sleep 61 # G-COLD: ventana nueva del presupuesto owner (6 msg/60s) +echo "" +echo "=== SIM JR — José Ramón (5213319706935, contacto REAL): tarjeta simulada, CERO egress ===" +JR="5213319706935" +# SIN limpieza y SIN approve: solo persona → selector (las tarjetas van al OWNER). + +echo "--- STEP 1 — última tarjeta del owner para JR ---" +JR_CARD=$(psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label' LIKE '%${JR}%' ORDER BY id DESC LIMIT 1") +JR_CARD_LABEL="" +[ -n "$JR_CARD" ] && JR_CARD_LABEL=$(psql_q "SELECT template_meta->>'label' FROM coexistence.chat_history WHERE message_id='$JR_CARD' ORDER BY id DESC LIMIT 1") +# owner_pipe exige contexto de tarjeta de CAPTURA; si la última tarjeta es otra +# (selector/approve) o no existe, re-compartimos su vCard (egress solo al owner). +case "$JR_CARD_LABEL" in + owner_vcard_captured_${JR}*) ok "tarjeta de captura previa encontrada (msg_id=$JR_CARD)";; + *) + echo " [INFO] sin tarjeta de captura reciente (label='$JR_CARD_LABEL'); re-comparto vCard" + ST=$(inject_vcard "$JR" "José Ramón" "José" "vcard.jr") + chk "webhook vCard JR HTTP" "$ST" "200" + JR_CARD=$(wait_owner_msg "owner_vcard_captured_$JR" 90) + [ -n "$JR_CARD" ] && ok "tarjeta de captura JR (msg_id=$JR_CARD)" || bad "tarjeta captura JR" "" "message_id" + ;; +esac + +echo "--- STEP 2 — owner clasifica a JR como aliado → selector con marcas frías ---" +ST=$(inject_interactive "owner_pipe:$JR:persona_aliado" "$JR_CARD" "Aliado / socio") +chk "webhook persona JR HTTP" "$ST" "200" +sleep 8 +JR_SEL=$(wait_owner_msg "owner_sequence_selector_${JR}_persona_aliado" 90) +[ -n "$JR_SEL" ] && ok "selector enviado al owner (msg_id=$JR_SEL)" || bad "selector JR" "" "message_id" + +echo "--- STEP 3 — la tarjeta simulada (ranking persistido completo) ---" +psql_q "SELECT jsonb_pretty(custom_fields->'ia360_selector_ranking') FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$JR'" +JR_DESC=$(psql_q "SELECT r->>'description' FROM coexistence.contacts c, jsonb_array_elements(c.custom_fields->'ia360_selector_ranking'->'rows') r WHERE c.wa_number='$WA' AND c.contact_number='$JR' AND r->>'title'='Criterios de fit' LIMIT 1") +chk_any "row Criterios de fit con marca de disponibilidad" "$JR_DESC" "template|✓" + +echo "--- STEP 4 — CERO egress a JR durante la simulación ---" +JR_EGRESS=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$JR' AND created_at > now() - interval '10 minutes'") +chk "cero salientes recientes a JR" "$JR_EGRESS" "0" + +# ============================================================================ +echo "" +echo "=== RESULTADO G-COLD: PASS=$PASS FAIL=$FAIL ===" +[ "$FAIL" = "0" ] && exit 0 || exit 1 diff --git a/gcold-simc.sh b/gcold-simc.sh new file mode 100644 index 0000000..74149c8 --- /dev/null +++ b/gcold-simc.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E G-COLD — cold send con templates fríos para las 24 secuencias: +# selector con disponibilidad «lista para frío» fuera de ventana, aviso en el +# readout, tarjeta sin "Aprobar y enviar" cuando no hay template aprobado, +# cinturón en approve-send y envío real como template de Meta. +# SIM A — sponsor_diagnostico (APPROVED) → sale como template. +# SIM B — cfo_control (APPROVED) → sale como template. +# SIM C — aliado_mapa_colaboracion (v2 APPROVED) → sale como template. +# SIM D — aliado_criterios_fit (template en revisión) → bloqueado de punta a punta. +# SIM JR — José Ramón (contacto REAL): solo tarjeta simulada, CERO egress a él. +# Uso: bash gcold-e2e.sh (correr en el VPS) +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="873315362541590" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado='$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "$2" "contiene:$3"; fi; } +chk_not_has(){ if echo "$2" | grep -qF "$3"; then bad "$1" "$2" "NO contiene:$3"; else ok "$1"; fi; } +chk_any(){ # $1=nombre $2=valor $3=patron grep -E (alternativas) + if echo "$2" | grep -qE "$3"; then ok "$1"; else bad "$1" "$2" "matchea:$3"; fi +} + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } +WAMID_BASE="wamid.e2e.gcold.$(ts)" + +owner_msg_id_by_label(){ # $1=label → message_id de la última saliente al owner con ese label + psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} +owner_body_by_label(){ + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} + +# G-COLD: espera con polling a que aparezca la saliente al owner con ese label. +# El presupuesto owner es 6 mensajes/60s: el polling absorbe la latencia de la +# cola sin depender de sleeps exactos. +wait_owner_msg(){ # $1=label $2=timeout_s (default 90) -> imprime message_id + local deadline=$(( $(date +%s) + ${2:-90} )) + local mid="" + while [ "$(date +%s)" -lt "$deadline" ]; do + mid=$(owner_msg_id_by_label "$1") + [ -n "$mid" ] && { printf '%s' "$mid"; return 0; } + sleep 5 + done + printf '%s' "" +} + +inject_interactive(){ # $1=reply_id $2=context_msg_id $3=title + local body + body=$(cat </dev/null + psql_q "DELETE FROM coexistence.chat_history WHERE contact_number='$1'" >/dev/null + psql_q "DELETE FROM coexistence.contacts WHERE contact_number='$1'" >/dev/null +} + +# ──────────────────────────────────────────────────────────────────────────── +# run_cold_sim — flujo feliz fuera de ventana: vCard → persona → selector con +# marcas frías → secuencia (readout con aviso de template) → tarjeta → aprobar +# → el opener sale como TEMPLATE de Meta. +# $1=num $2=vcard_name $3=persona_id $4=persona_title $5=seq_id $6=seq_title $7=template_name +# ──────────────────────────────────────────────────────────────────────────── +run_cold_sim(){ + local NUM="$1" VNAME="$2" PERSONA="$3" PTITLE="$4" SEQ="$5" STITLE="$6" TPL="$7" + + echo "--- STEP 0 — limpieza estado QA $NUM ---" + clean_qa_number "$NUM" + ok "estado limpio ($NUM)" + + echo "--- STEP 1 — owner comparte vCard \"$VNAME\" ---" + local ST; ST=$(inject_vcard "$NUM" "$VNAME" "QA" "vcard.$NUM") + chk "webhook vCard HTTP" "$ST" "200" + + local CARD1; CARD1=$(wait_owner_msg "owner_vcard_captured_$NUM" 90) + [ -n "$CARD1" ] && ok "tarjeta de captura (msg_id=$CARD1)" || bad "tarjeta captura" "" "message_id" + + echo "--- STEP 2 — owner elige persona: $PTITLE ---" + ST=$(inject_interactive "owner_pipe:$NUM:$PERSONA" "$CARD1" "$PTITLE") + chk "webhook persona HTTP" "$ST" "200" + sleep 8 + local CARD2; CARD2=$(wait_owner_msg "owner_sequence_selector_${NUM}_${PERSONA}" 90) + [ -n "$CARD2" ] && ok "selector de secuencias (msg_id=$CARD2)" || bad "selector secuencias" "" "message_id" + + echo "--- STEP 3 — asserts del ranking persistido (modo frío) ---" + local OUTW; OUTW=$(psql_q "SELECT custom_fields->'ia360_selector_ranking'->'cold'->>'outside_window' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$NUM'") + chk "cold.outside_window persistido" "$OUTW" "true" + local AVLBL; AVLBL=$(psql_q "SELECT custom_fields->'ia360_selector_ranking'->'cold'->'availability'->'$SEQ'->>'label' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$NUM'") + chk "availability($SEQ) = lista para frío" "$AVLBL" "✓ lista para frío" + + echo "--- STEP 4 — owner elige secuencia: $STITLE ---" + ST=$(inject_interactive "owner_seq:$NUM:$SEQ" "$CARD2" "$STITLE") + chk "webhook secuencia HTTP" "$ST" "200" + sleep 7 + local READOUT; READOUT=$(owner_body_by_label "owner_sequence_readout_$SEQ") + chk_has "readout avisa salida como template" "$READOUT" "saldrá como template aprobado" + local CARD3; CARD3=$(wait_owner_msg "owner_approve_card_${NUM}_${SEQ}" 90) + [ -n "$CARD3" ] && ok "tarjeta de aprobación (msg_id=$CARD3)" || bad "tarjeta aprobacion" "" "message_id" + + echo "--- STEP 5 — owner tap: Aprobar y enviar ---" + ST=$(inject_interactive "owner_approve_send:$NUM:$SEQ" "$CARD3" "Aprobar y enviar") + chk "webhook aprobar HTTP" "$ST" "200" + sleep 10 + + echo "--- STEP 6 — el opener salió como TEMPLATE de Meta ---" + local TROW; TROW=$(psql_q "SELECT template_meta->>'template_name' || '|' || status || '|' || COALESCE(error_message,'-') FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$NUM' AND message_type='template' ORDER BY id DESC LIMIT 1") + local TNAME TSTATUS TERR + TNAME=$(echo "$TROW" | cut -d'|' -f1) + TSTATUS=$(echo "$TROW" | cut -d'|' -f2) + TERR=$(echo "$TROW" | cut -d'|' -f3-) + echo " [TEMPLATE] name=$TNAME status=$TSTATUS error=$TERR" + chk "template_name correcto" "$TNAME" "$TPL" + chk_any "status del template en sent|failed" "$TSTATUS" "^(sent|failed)$" +} + +echo "" +echo "=== REGRESIÓN SIM C — aliado (5219990077703): aliado_mapa_colaboracion v2 como template frío ===" +run_cold_sim "5219990077703" "QA Aliado GCold" "persona_aliado" "Aliado / socio" "aliado_mapa_colaboracion" "Mapa colaboración" "ia360_aliado_mapa_colaboracion_v2" + +echo "" +echo "=== RESULTADO REGRESIÓN C: PASS=$PASS FAIL=$FAIL ===" +[ "$FAIL" = "0" ] && exit 0 || exit 1 diff --git a/gcold-simd.sh b/gcold-simd.sh new file mode 100644 index 0000000..ca5e3b7 --- /dev/null +++ b/gcold-simd.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E G-COLD — cold send con templates fríos para las 24 secuencias: +# selector con disponibilidad «lista para frío» fuera de ventana, aviso en el +# readout, tarjeta sin "Aprobar y enviar" cuando no hay template aprobado, +# cinturón en approve-send y envío real como template de Meta. +# SIM A — sponsor_diagnostico (APPROVED) → sale como template. +# SIM B — cfo_control (APPROVED) → sale como template. +# SIM C — aliado_mapa_colaboracion (v2 APPROVED) → sale como template. +# SIM D — aliado_criterios_fit (template en revisión) → bloqueado de punta a punta. +# SIM JR — José Ramón (contacto REAL): solo tarjeta simulada, CERO egress a él. +# Uso: bash gcold-e2e.sh (correr en el VPS) +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="873315362541590" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado='$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "$2" "contiene:$3"; fi; } +chk_not_has(){ if echo "$2" | grep -qF "$3"; then bad "$1" "$2" "NO contiene:$3"; else ok "$1"; fi; } +chk_any(){ # $1=nombre $2=valor $3=patron grep -E (alternativas) + if echo "$2" | grep -qE "$3"; then ok "$1"; else bad "$1" "$2" "matchea:$3"; fi +} + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } +WAMID_BASE="wamid.e2e.gcold.$(ts)" + +owner_msg_id_by_label(){ # $1=label → message_id de la última saliente al owner con ese label + psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} +owner_body_by_label(){ + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} + +# G-COLD: espera con polling a que aparezca la saliente al owner con ese label. +# El presupuesto owner es 6 mensajes/60s: el polling absorbe la latencia de la +# cola sin depender de sleeps exactos. +wait_owner_msg(){ # $1=label $2=timeout_s (default 90) -> imprime message_id + local deadline=$(( $(date +%s) + ${2:-90} )) + local mid="" + while [ "$(date +%s)" -lt "$deadline" ]; do + mid=$(owner_msg_id_by_label "$1") + [ -n "$mid" ] && { printf '%s' "$mid"; return 0; } + sleep 5 + done + printf '%s' "" +} + +inject_interactive(){ # $1=reply_id $2=context_msg_id $3=title + local body + body=$(cat </dev/null + psql_q "DELETE FROM coexistence.chat_history WHERE contact_number='$1'" >/dev/null + psql_q "DELETE FROM coexistence.contacts WHERE contact_number='$1'" >/dev/null +} + +# ──────────────────────────────────────────────────────────────────────────── +# ============================================================================ +sleep 61 # G-COLD: ventana nueva del presupuesto owner (6 msg/60s) +echo "" +echo "=== SIM D — aliado (5219990077704): template EN REVISIÓN bloquea de punta a punta ===" +NUMD="5219990077704" + +echo "--- STEP 0 — limpieza estado QA $NUMD ---" +clean_qa_number "$NUMD" +ok "estado limpio ($NUMD)" + +echo "--- STEP 1 — vCard + persona aliado ---" +ST=$(inject_vcard "$NUMD" "QA Aliado Pending GCold" "QA" "vcard.$NUMD") +chk "webhook vCard HTTP" "$ST" "200" +sleep 6 +CARD1=$(wait_owner_msg "owner_vcard_captured_$NUMD" 90) +[ -n "$CARD1" ] && ok "tarjeta de captura (msg_id=$CARD1)" || bad "tarjeta captura" "" "message_id" +ST=$(inject_interactive "owner_pipe:$NUMD:persona_aliado" "$CARD1" "Aliado / socio") +chk "webhook persona HTTP" "$ST" "200" +sleep 7 +CARD2=$(wait_owner_msg "owner_sequence_selector_${NUMD}_persona_aliado" 90) +[ -n "$CARD2" ] && ok "selector de secuencias (msg_id=$CARD2)" || bad "selector secuencias" "" "message_id" + +echo "--- STEP 2 — availability marca el template en revisión ---" +AVLBL=$(psql_q "SELECT custom_fields->'ia360_selector_ranking'->'cold'->'availability'->'aliado_criterios_fit'->>'label' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$NUMD'") +chk "availability(aliado_criterios_fit) en revisión" "$AVLBL" "template en revisión Meta" + +echo "--- STEP 3 — owner elige secuencia: Criterios de fit ---" +ST=$(inject_interactive "owner_seq:$NUMD:aliado_criterios_fit" "$CARD2" "Criterios de fit") +chk "webhook secuencia HTTP" "$ST" "200" +sleep 7 +READOUT=$(owner_body_by_label "owner_sequence_readout_aliado_criterios_fit") +chk_has "readout trae AVISO" "$READOUT" "AVISO" +chk_has "readout dice no aprobado por Meta" "$READOUT" "no está aprobado por Meta" +COLDBLK=$(psql_q "SELECT custom_fields->>'ia360_approve_card_cold_blocked' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$NUMD'") +chk "ia360_approve_card_cold_blocked = true" "$COLDBLK" "true" +CARD3=$(wait_owner_msg "owner_approve_card_${NUMD}_aliado_criterios_fit" 90) +[ -n "$CARD3" ] && ok "tarjeta de aprobación (msg_id=$CARD3)" || bad "tarjeta aprobacion" "" "message_id" +CARD3_BODY=$(psql_q "SELECT raw_payload::text FROM coexistence.chat_history WHERE message_id='$CARD3' LIMIT 1") +chk_not_has "tarjeta SIN fila Aprobar y enviar" "$CARD3_BODY" "owner_approve_send:$NUMD:aliado_criterios_fit" + +echo "--- STEP 4 — tap de tarjeta vieja: owner_approve_send debe bloquearse ---" +ST=$(inject_interactive "owner_approve_send:$NUMD:aliado_criterios_fit" "$CARD3" "Aprobar y enviar") +chk "webhook aprobar HTTP" "$ST" "200" +sleep 8 +BLOCKED=$(owner_body_by_label "owner_approve_send_blocked") +chk_has "cinturón avisa template no aprobado" "$BLOCKED" "aún no está aprobado por Meta" +TCOUNT=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$NUMD' AND message_type='template'") +chk "cero templates salientes al QA" "$TCOUNT" "0" + +echo "" +echo "=== RESULTADO SIM D: PASS=$PASS FAIL=$FAIL ===" +[ "$FAIL" = "0" ] && exit 0 || exit 1 diff --git a/gcold-templates.js b/gcold-templates.js new file mode 100644 index 0000000..cd5669e --- /dev/null +++ b/gcold-templates.js @@ -0,0 +1,153 @@ +// G-COLD: crea y somete a Meta los 17 templates fríos restantes (QUICK_REPLY) +// vía el flujo /api/templates existente. Corre DENTRO del contenedor backend +// (tiene jsonwebtoken y JWT_SECRET en env). Idempotente: si el nombre ya +// existe, reutiliza la fila; solo somete si el status NO es +// PENDING/APPROVED/SUBMITTED. Cada entrada puede definir samples propios +// (default {1:'Emmanuel'}). +const jwt = require('jsonwebtoken'); + +const BASE = 'http://localhost:3011/api'; +const FOOTER = 'Responde STOP si no quieres recibir esto'; +const SAMPLES = { 1: 'Emmanuel' }; + +const TEMPLATES = [ + { + name: 'ia360_beta_architectura', + body: 'Hola {{1}}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?', + buttons: ['Sí, pregúntame', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_beta_feedback', + body: 'Hola {{1}}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?', + buttons: ['Sí, pregúntame', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_beta_memoria', + body: 'Hola {{1}}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?', + buttons: ['Sí, a ver', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_referido_contexto', + body: 'Hola {{1}}, soy la IA de Alek. Te escribo porque nos presentó {{2}} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?', + buttons: ['Hazme una pregunta', 'Que me escriba Alek', 'Ahora no'], + samples: { 1: 'Emmanuel', 2: 'Carlos' }, + }, + { + name: 'ia360_referido_oneliner', + body: 'Hola {{1}}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?', + buttons: ['Sí, cuéntame más', 'Que me escriba Alek', 'Por ahora no'], + }, + { + name: 'ia360_aliado_criterios_fit', + body: 'Hola {{1}}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?', + buttons: ['Sí, pregúntame', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_aliado_caso_reventa', + body: 'Hola {{1}}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?', + buttons: ['Sí, compártelo', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_cliente_readout', + body: 'Hola {{1}}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?', + buttons: ['Sí, te cuento', 'Todo va bien', 'Que me escriba Alek'], + }, + { + name: 'ia360_cliente_soporte', + body: 'Hola {{1}}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?', + buttons: ['Sí, hay un tema', 'Todo en orden', 'Que me escriba Alek'], + }, + { + name: 'ia360_sponsor_fuga_valor', + body: 'Hola {{1}}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?', + buttons: ['Te respondo aquí', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_sponsor_caso_ndasafe', + body: 'Hola {{1}}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?', + buttons: ['Sí, mándalo', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_comercial_wa_crm', + body: 'Hola {{1}}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy: historial de clientes, seguimiento, prioridad de leads o datos para decidir?', + buttons: ['Te respondo aquí', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_comercial_motor_prospeccion', + body: 'Hola {{1}}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?', + buttons: ['Te respondo aquí', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_cfo_cartera_datos', + body: 'Hola {{1}}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?', + buttons: ['Te respondo aquí', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_cfo_comisiones', + body: 'Hola {{1}}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?', + buttons: ['Te respondo aquí', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_tecnico_rollback', + body: 'Hola {{1}}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada: permisos, datos, trazabilidad, reversibilidad o dependencia operativa?', + buttons: ['Te respondo aquí', 'Que me escriba Alek', 'Ahora no'], + }, + { + name: 'ia360_tecnico_integracion', + body: 'Hola {{1}}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?', + buttons: ['Te respondo aquí', 'Que me escriba Alek', 'Ahora no'], + }, +]; + +async function main() { + const token = jwt.sign( + { id: 1, username: 'admin', displayName: 'admin', role: 'admin' }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + const headers = { 'content-type': 'application/json', cookie: `forgecrm_token=${token}` }; + + const listRes = await fetch(`${BASE}/templates`, { headers }); + const listJson = await listRes.json().catch(() => null); + const existing = Array.isArray(listJson) ? listJson : (listJson?.templates || listJson?.rows || []); + + for (const t of TEMPLATES) { + let row = existing.find(x => x.name === t.name); + if (!row) { + const createRes = await fetch(`${BASE}/templates`, { + method: 'POST', + headers, + body: JSON.stringify({ + name: t.name, + category: 'MARKETING', + language: 'es_MX', + header_type: 'NONE', + body: t.body, + footer: FOOTER, + buttons: t.buttons.map(text => ({ type: 'QUICK_REPLY', text })), + samples: t.samples || SAMPLES, + }), + }); + const createJson = await createRes.json().catch(() => null); + if (!createRes.ok) { + console.log(`CREATE_FAIL ${t.name} ${createRes.status} ${JSON.stringify(createJson)}`); + continue; + } + row = createJson?.template || createJson; + console.log(`CREATED ${t.name} id=${row?.id} status=${row?.status}`); + } else { + console.log(`EXISTS ${t.name} id=${row.id} status=${row.status}`); + } + const status = String(row?.status || '').toUpperCase(); + if (!row?.id) continue; + if (['PENDING', 'APPROVED', 'SUBMITTED'].includes(status)) { + console.log(`SKIP_SUBMIT ${t.name} (status=${status})`); + continue; + } + const subRes = await fetch(`${BASE}/templates/${row.id}/submit`, { method: 'POST', headers }); + const subJson = await subRes.json().catch(() => null); + console.log(`SUBMIT ${t.name} http=${subRes.status} -> ${JSON.stringify(subJson).slice(0, 220)}`); + } +} + +main().catch(e => { console.error('FATAL', e.message); process.exit(1); }); From 30084f1eaaacc941c6dcb95c116271efe3a965eb Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Thu, 11 Jun 2026 20:12:39 +0000 Subject: [PATCH 27/39] =?UTF-8?q?fix(ia360):=20no-silencio=20P0=20cliente?= =?UTF-8?q?=20activo/beta=20=E2=80=94=20dry-run=20de=20memoria=20ya=20no?= =?UTF-8?q?=20cuenta=20como=20respuesta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleIa360ClienteActivoBetaLearning: la rama dry-run (IA360_MEMORY_EGRESS off) devolvía true sin egress real; el padre lo interpretaba como respondido y el contacto quedaba mudo (incidente Andrés 2026-06-11, 38 min sin respuesta, inbound 17:23 UTC). Ahora devuelve false. - handleIa360FreeText: si el handler de cliente activo/beta devuelve false, envía holding al contacto + alerta al owner + registra failure en ia360_bot_failures (nunca silencio). - handleIa360BotFailure: copy del fallback más ejecutivo y honesto. - Nuevo test de regresión backend/test/ia360NoSilenceRegression.test.js (PASS). --- backend/src/routes/webhook.js | 24 +++++++++++++++---- backend/test/ia360NoSilenceRegression.test.js | 20 ++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 backend/test/ia360NoSilenceRegression.test.js diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 3513cd7..6a8dbbd 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -759,14 +759,18 @@ async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { ia360_memory_last_owner_readout_preview: ownerReadout, }, }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); - console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run -> fallback_required', maskIa360Number(record.contact_number), deal?.memory_mode || 'cliente_activo_beta', persisted.filter(x => x.eventId).length, persisted.filter(x => x.factId).length, deal?.stage_name || '-' ); - return true; + // Important: dry-run memory learning is NOT a customer response. Returning true + // made the parent handler believe the message was handled, which produced the + // active-client silence bug. Return false so handleIa360FreeText sends the + // universal holding fallback + owner alert instead of leaving WhatsApp quiet. + return false; } await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); sendIa360DirectText({ @@ -5470,7 +5474,7 @@ function isIa360PassiveMessage(body) { // `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). // `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { - const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + const fallbackBody = 'Recibí tu pregunta. Para responderte bien y no darte una respuesta incompleta, voy a revisar el contexto con Alek y te confirmo por aquí en breve.'; let failureId = null; try { const { rows } = await pool.query( @@ -5538,7 +5542,19 @@ async function handleIa360FreeText(record) { const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); - if (handled) responded = true; + if (handled) { + responded = true; + return; + } + // Cliente activo/beta con pregunta sustantiva pero sin señales suficientes para + // contestar desde memoria: nunca inventar ni dejar en silencio. Enviar holding + // claro al contacto y alertar a Alek para investigar el proyecto/fuente canónica. + await handleIa360BotFailure({ + record, + reason: 'cliente activo/beta sin contexto suficiente en memoria para responder sin alucinar', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] cliente-activo fallback error:', e.message)); + responded = true; return; } const agent = await callIa360Agent({ record, stageName: deal.stage_name }); diff --git a/backend/test/ia360NoSilenceRegression.test.js b/backend/test/ia360NoSilenceRegression.test.js new file mode 100644 index 0000000..7085c51 --- /dev/null +++ b/backend/test/ia360NoSilenceRegression.test.js @@ -0,0 +1,20 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +test('cliente activo beta memory dry-run must fall through to no-silence fallback', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'webhook.js'), 'utf8'); + const dryRunStart = src.indexOf('if (!IA360_MEMORY_EGRESS_ON)'); + const dryRunEnd = src.indexOf("await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply'", dryRunStart); + assert.notEqual(dryRunStart, -1, 'dry-run memory branch missing'); + assert.notEqual(dryRunEnd, -1, 'egress-on memory branch missing'); + const dryRunBlock = src.slice(dryRunStart, dryRunEnd); + + assert.match(dryRunBlock, /egress=dry_run -> fallback_required/); + assert.match(dryRunBlock, /return false;/, 'dry-run memory learning is not a customer response'); + + const parentFallback = src.slice(src.indexOf('async function handleIa360FreeText'), src.indexOf('const agent = await callIa360Agent')); + assert.match(parentFallback, /handleIa360BotFailure/); + assert.match(parentFallback, /cliente activo\/beta sin contexto suficiente/); +}); From a4581dd4873e6e063fc4817ae1b3e974e052a6da Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Thu, 11 Jun 2026 21:21:10 +0000 Subject: [PATCH 28/39] =?UTF-8?q?feat(ia360):=20G-LIVE=20no-silencio=20pro?= =?UTF-8?q?ducci=C3=B3n=20=E2=80=94=20respuesta=20real=20del=20agente,=20i?= =?UTF-8?q?nvariante=20watchdog=20y=20guard=20del=20inyector=20QA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cliente activo/beta SIEMPRE recibe respuesta real (P0, incidente Andrés 38 min mudo): - Rama beta de handleIa360FreeText: captura de memoria en segundo plano (captureOnly, nunca cuenta como respuesta) + respuesta REAL vía callIa360Agent con memoria y perfil ejecutivo (roleHint viaja como role en el payload; el Normalize del workflow lo expone al modelo SIN tocar n8n publicado). Holding + alerta + failure SOLO si el agente falla. Datos vivos del portal => handoff explícito, nunca inventar listas. - Invariante estructural de CLASE (watchdog 75 s en el dispatcher): tras cada inbound de contacto activo/beta (o deal ganado), si no hay fila outgoing real en chat_history (excluyendo failed) => holding + alerta al owner + failure. Cubre texto, botones, audio y vCard; boot-rescan de 15 min cubre deploys/restarts. - Guard del inyector QA (incidente José Ramón): payloads sintéticos (wamid e2e./qa-*/ harness) hacia números fuera de 52199900* y distintos del owner se descartan antes del INSERT: sin fila, sin handlers, sin egress derivado. - Cierres de la auditoría adversarial (51 agentes, 7 confirmados): catch del dispatch con verificación en chat_history, holding+alerta en el catch del slot-confirm, ack de vCard al remitente no-owner (incluso parse-fail), guard owner-only del canary Brain v2 en código. - Tests: ia360NoSilenceRegression.test.js ampliado a 4 casos (PASS). E2E nuevo glive-e2e.sh 13/13 PASS (respuesta real, agente caído, guard, watchdog). Regresión cold-send G-COLD sin romper (54 PASS; los FAIL del SIM D son fixture caducado: aliado_criterios_fit ya APPROVED por Meta el 06-11). - Flags: NINGUNO activado. IA360_MEMORY_EGRESS sigue off; la respuesta real ya no depende de ese flag y queda acotada por código a la rama cliente activo/beta, conforme a la política de producción de Alek del 06-11 (egress real como respuesta a contactos reales que escriben; prohibido iniciar conversaciones o pitch a do_not_pitch). --- backend/src/routes/webhook.js | 338 +++++++++++++++++- backend/test/ia360NoSilenceRegression.test.js | 101 +++++- glive-e2e.sh | 128 +++++++ 3 files changed, 548 insertions(+), 19 deletions(-) create mode 100755 glive-e2e.sh diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 6a8dbbd..8aed475 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -350,6 +350,37 @@ function isIa360ClienteActivoBetaContact(contact) { || tags.includes('cliente-activo-beta'); } +// G-LIVE: números QA del harness E2E (pruebas sintéticas autorizadas). Longitud +// exacta de 13 dígitos (521 + 99900XXXXX): un '\d*' laxo dejaría pasar móviles +// reales de la lada 999 (Mérida) como si fueran QA. +const IA360_QA_NUMBER_RE = /^52199900\d{5}$/; + +// G-LIVE: pregunta que requiere DATOS VIVOS del portal del cliente (saldos, listas, +// estatus). Lectura de estado => handoff explícito, nunca inventar. La guía de uso +// ("¿cómo subo información al portal?") NO matchea: esa sí la responde el agente. +function isIa360PortalLiveDataQuestion(body) { + const t = String(body || '').toLowerCase(); + if (!/(portal|plataforma)/.test(t)) return false; + return /(cu[aá]nt[oa]s?\b|cu[aá]les\b|qu[eé] (hay|tiene|dice|muestra|aparece)|lista(do)?\s+de|estatus|estado de|sald[oa]s?|pendientes? de pago|cargad[oa]s?|registrad[oa]s?)/.test(t); +} + +// G-LIVE: perfil de comunicación para el agente IA. Viaja como `role` en el payload; +// el nodo Normalize del workflow n8n lo expone al modelo como contact.role (sin tocar +// n8n publicado). do_not_pitch => tono ejecutivo: corto, negocio, control/riesgo. +function buildIa360ClienteActivoBetaRoleHint(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const role = beta.contact_role || cf.project_role || cf.rol_comite || 'cliente activo beta'; + const noPitchRaw = beta.do_not_pitch != null ? beta.do_not_pitch : cf.do_not_pitch; + const noPitch = noPitchRaw === true || noPitchRaw === 1 + || /^(true|1|s[ií]|yes)$/i.test(String(noPitchRaw || '')) + || /cfo|champion|finanzas/i.test(String(role)); + const parts = [String(role), 'cliente activo en beta supervisada']; + if (noPitch) parts.push('do_not_pitch: PROHIBIDO vender, pitchear u ofrecer nuevos servicios'); + parts.push('estilo ejecutivo: respuesta corta, implicación de negocio, control y riesgo, sin detalle técnico salvo que lo pida'); + return parts.join(' | '); +} + function getIa360ContactProfile(contact) { const cf = contact?.custom_fields || {}; const beta = cf.ia360_cliente_activo_beta || {}; @@ -727,7 +758,7 @@ function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { return lines.join('\n'); } -async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact, captureOnly = false }) { const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { console.error('[ia360-memory] lookup before reply:', err.message); return { events: [], facts: [] }; @@ -747,6 +778,19 @@ async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', }, }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + if (captureOnly) { + // G-LIVE: la captura de memoria NUNCA es respuesta al cliente. El caller (rama + // cliente activo/beta de handleIa360FreeText) responde con el agente IA; aquí + // solo se aprende en segundo plano. + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=capture_only', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return false; + } const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); if (!IA360_MEMORY_EGRESS_ON) { @@ -2322,7 +2366,7 @@ async function shadowIa360ContactIntelligence({ record, stageName, history }) { } } -async function callIa360Agent({ record, stageName }) { +async function callIa360Agent({ record, stageName, roleHint = null }) { // Recent conversation for context (last 8 messages). const { rows: hist } = await pool.query( `SELECT direction AS dir, message_body AS body @@ -2367,6 +2411,9 @@ async function callIa360Agent({ record, stageName }) { source: 'forgechat-ia360-webhook', }), memory: agentMemory, + // G-LIVE: el Normalize del workflow mapea `role` a contact.role dentro del + // agent_input — perfil de comunicación visible para el modelo sin tocar n8n. + ...(roleHint ? { role: roleHint } : {}), }), signal: ia360AgentController.signal, }); @@ -4580,6 +4627,14 @@ async function handleIa360SharedContacts(record) { body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', ownerBudget: true, }); + // G-LIVE: el remitente tampoco debe quedar mudo cuando la tarjeta no se pudo leer. + if (!isIa360OwnerNumber(record.contact_number)) { + await enqueueIa360Text({ + record, + label: 'ia360_vcard_ack', + body: 'Recibí la tarjeta, pero no pude leer bien el número. Se la paso a Alek para revisarla y te confirmo por aquí.', + }).catch(e => console.error('[ia360-vcard] parse-fail ack error:', e.message)); + } return true; } @@ -4613,6 +4668,15 @@ async function handleIa360SharedContacts(record) { }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); await notifyOwnerVcardCaptured({ record, shared }); } + // G-LIVE (auditoría 06-11): un CONTACTO (no owner) que comparte una tarjeta + // merece acuse — antes solo se notificaba al owner y el remitente quedaba mudo. + if (!isIa360OwnerNumber(record.contact_number)) { + await enqueueIa360Text({ + record, + label: 'ia360_vcard_ack', + body: 'Recibí la tarjeta, gracias. La registro y le digo a Alek; cualquier siguiente paso te lo confirmo por aquí.', + }).catch(e => console.error('[ia360-vcard] contact ack error:', e.message)); + } return true; } catch (err) { console.error('[ia360-vcard] handler error:', err.message); @@ -5523,6 +5587,125 @@ async function handleIa360BotFailure({ record, reason, alreadyResponded = false return failureId; } +// ── G-LIVE: invariante estructural no-silencio (watchdog por inbound) ──────────── +// La clase del incidente Andrés (38 min mudo): una rama "marca como manejado" sin +// egress real. La verdad no es ningún flag en memoria: es la fila outgoing en +// chat_history (insertPendingRow la escribe al encolar, así que es visible de +// inmediato). 75 s después del inbound de un cliente activo/beta (o con deal +// ganado), si no existe NINGÚN outgoing hacia ese contacto desde el inbound => +// holding + alerta al owner + failure registrado. Cubre toda rama presente o +// futura (texto, botones, audio, vCard) sin parchar rama por rama. El dedupe de +// resolveIa360Outbound (ia360_handler_for = message_id) lo hace idempotente. +const IA360_NO_SILENCE_WATCHDOG_MS = 75000; + +async function ia360HasOutgoingForInbound(record) { + // Escape de comodines LIKE: los wamid del harness pueden traer '_' o '%'. + const wamidLike = String(record.message_id || '~none~').replace(/[\\%_]/g, '\\$&'); + const { rows } = await pool.query( + `SELECT 1 + FROM coexistence.chat_history o + WHERE o.direction = 'outgoing' + AND o.contact_number = $1 + AND COALESCE(o.status, '') NOT IN ('failed', 'error') + AND (o.template_meta->>'ia360_handler_for' LIKE $2 || '%' + OR o.timestamp >= (SELECT i.timestamp FROM coexistence.chat_history i + WHERE i.message_id = $3 LIMIT 1)) + LIMIT 1`, + [record.contact_number, wamidLike, record.message_id || '~none~'] + ); + return rows.length > 0; +} + +async function ia360IsWatchedActiveContact(record) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) return true; + // Cliente con deal GANADO sin marca beta en contacts: también es cliente real + // activo que jamás debe quedar en silencio (hallazgo alta de la auditoría 06-11). + try { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.contact_wa_number = $1 AND d.contact_number = $2 + AND (s.stage_type = 'won' OR s.name = 'Ganado') + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows.length > 0; + } catch (e) { + console.error('[ia360-no-silence] won-deal check error:', e.message); + return false; + } +} + +async function ia360NoSilenceWatchdogCheck(record) { + if (!(await ia360IsWatchedActiveContact(record))) return; + if (await ia360HasOutgoingForInbound(record)) return; + console.error('[ia360-no-silence] INVARIANTE DISPARADO contact=%s message=%s type=%s', + maskIa360Number(record.contact_number), record.message_id, record.message_type); + await handleIa360BotFailure({ + record, + reason: `invariante no-silencio: sin fila outgoing en chat_history ${Math.round(IA360_NO_SILENCE_WATCHDOG_MS / 1000)}s después del inbound de cliente activo/beta`, + alreadyResponded: false, + }); +} + +function scheduleIa360NoSilenceWatchdog(record) { + try { + if (!record || record.direction !== 'incoming') return; + if (record.message_type === 'status' || record.message_type === 'reaction' || record.message_type === 'sticker') return; + if (!record.message_id) return; // sin wamid no hay forma confiable de verificar el egress + const n = normalizePhone(record.contact_number); + if (!n || n === IA360_OWNER_NUMBER) return; + // Texto pasivo ("gracias", "ok") no exige respuesta (diseño deliberado Gap#1). + if (record.message_type === 'text' && isIa360PassiveMessage(record.message_body)) return; + // Subset del record: no retener raw_payload (batches n8n) en memoria 75 s. + const slim = { + wa_number: record.wa_number, + contact_number: record.contact_number, + contact_name: record.contact_name || null, + message_id: record.message_id, + message_type: record.message_type, + message_body: record.message_body || null, + direction: record.direction, + }; + const t = setTimeout(() => { + ia360NoSilenceWatchdogCheck(slim).catch(e => + console.error('[ia360-no-silence] watchdog error:', e.message)); + }, IA360_NO_SILENCE_WATCHDOG_MS); + if (typeof t.unref === 'function') t.unref(); + } catch (e) { + console.error('[ia360-no-silence] schedule error:', e.message); + } +} + +// G-LIVE: un deploy/restart dentro de la ventana del watchdog perdería los timers en +// memoria — y el deploy es justo el momento de mayor riesgo de egress roto. Al boot, +// re-escanear los inbounds recientes (15 min) de no-owner y re-correr el check. +const ia360BootRescanTimer = setTimeout(async () => { + try { + const { rows } = await pool.query( + `SELECT message_id, wa_number, contact_number, message_type, message_body + FROM coexistence.chat_history + WHERE direction = 'incoming' + AND message_type NOT IN ('status', 'reaction', 'sticker') + AND timestamp > NOW() - INTERVAL '15 minutes'` + ); + let checked = 0; + for (const r of rows) { + if (!r.message_id) continue; + if (normalizePhone(r.contact_number) === IA360_OWNER_NUMBER) continue; + if (r.message_type === 'text' && isIa360PassiveMessage(r.message_body)) continue; + checked += 1; + await ia360NoSilenceWatchdogCheck({ ...r, direction: 'incoming' }) + .catch(e => console.error('[ia360-no-silence] boot rescan check:', e.message)); + } + if (checked) console.log('[ia360-no-silence] boot rescan: %d inbound(s) recientes revisados', checked); + } catch (e) { + console.error('[ia360-no-silence] boot rescan error:', e.message); + } +}, 90000); +if (typeof ia360BootRescanTimer.unref === 'function') ia360BootRescanTimer.unref(); + async function handleIa360FreeText(record) { // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. @@ -5541,17 +5724,70 @@ async function handleIa360FreeText(record) { dealFound = true; const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { - const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); - if (handled) { - responded = true; + // G-LIVE (P0 producción): cliente activo/beta SIEMPRE recibe respuesta real. + // 1) Captura de memoria: aprendizaje en segundo plano REAL (fire-and-forget, + // no suma latencia al camino caliente) y NUNCA cuenta como respuesta (la + // clase del bug de Andrés: dry-run devolvía true y el padre creía que ya + // había respondido). + handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext, captureOnly: true }) + .catch(e => console.error('[ia360-memory] capture error:', e.message)); + + // 2) Datos vivos del portal del cliente: WhatsApp no tiene acceso => handoff + // explícito y honesto. NUNCA inventar listas ni cifras. + if (isIa360PortalLiveDataQuestion(record.message_body)) { + const sentHandoff = await enqueueIa360Text({ + record, + label: 'ia360_cliente_activo_portal_handoff', + body: 'Ese dato vive en el portal y prefiero confirmarte la cifra exacta antes que darte una aproximación. Lo reviso con Alek y te confirmo por aquí en breve.', + }); + if (sentHandoff) responded = true; + await handleIa360BotFailure({ + record, + reason: 'handoff: pregunta de datos vivos del portal del cliente (sin acceso desde WhatsApp)', + alreadyResponded: sentHandoff === true, + }).catch(e => console.error('[ia360-failure] portal handoff alert error:', e.message)); return; } - // Cliente activo/beta con pregunta sustantiva pero sin señales suficientes para - // contestar desde memoria: nunca inventar ni dejar en silencio. Enviar holding - // claro al contacto y alertar a Alek para investigar el proyecto/fuente canónica. + + // 3) Respuesta REAL del agente IA (memoria incluida vía memory-lookup del + // workflow). UNA sola respuesta; prohibidas cadenas de "corrijo". + // [qa-force-agent-down] simula agente caído SOLO para números QA (E2E). + const qaForceDown = IA360_QA_NUMBER_RE.test(String(record.contact_number || '')) + && /\[qa-force-agent-down\]/i.test(String(record.message_body || '')); + const agentBeta = qaForceDown + ? null + : await callIa360Agent({ + record, + stageName: deal.stage_name, + roleHint: buildIa360ClienteActivoBetaRoleHint(contactContext), + }); + const betaReply = agentBeta && typeof agentBeta.reply === 'string' ? agentBeta.reply.trim() : ''; + if (betaReply) { + const sentBeta = await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_agent_reply', body: betaReply }); + if (sentBeta) { + // Gestión de citas detectada por el agente: la acción de calendario del + // embudo no aplica al pseudo-deal beta, así que el loop lo cierra Alek. + const betaAction = String(agentBeta.action || agentBeta.intent || ''); + if (['list_bookings', 'cancel', 'reschedule', 'offer_slots', 'book'].includes(betaAction)) { + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_beta_meeting_intent', + body: `IA360: el cliente activo/beta ${record.contact_name || record.contact_number} pidió gestión de cita (${betaAction}): "${String(record.message_body || '').slice(0, 140)}". Ya le respondí, pero la acción de calendario requiere tu confirmación.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-agent] beta meeting alert error:', e.message)); + } + responded = true; + return; + } + } + + // 4) Holding SOLO si el agente falló o no devolvió respuesta utilizable: + // holding al contacto + alerta al owner + failure registrado. await handleIa360BotFailure({ record, - reason: 'cliente activo/beta sin contexto suficiente en memoria para responder sin alucinar', + reason: 'cliente activo/beta: agente IA sin respuesta utilizable (caído, timeout o reply vacío)', alreadyResponded: false, }).catch(e => console.error('[ia360-failure] cliente-activo fallback error:', e.message)); responded = true; @@ -7109,6 +7345,19 @@ async function handleIa360LiteInteractive(record) { }); } catch (e) { console.error('[ia360-calendar] slot confirm prompt error:', e.message); + // G-LIVE (auditoría 06-11): el prospecto ELIGIÓ hora; si el prompt de + // confirmación falla, no puede quedar mudo en el paso más caliente del + // booking — y Alek debe enterarse para cerrar la cita manualmente. + await enqueueIa360Text({ + record, + label: 'ia360_lite_slot_confirm_fallback', + body: 'Vi que elegiste un horario. Déjame confirmarlo con Calendar y te escribo en un momento para cerrarlo.', + }).catch(() => {}); + await handleIa360BotFailure({ + record, + reason: 'booking: falló el prompt de confirmación de horario (' + String(e.message || '').slice(0, 80) + ')', + alreadyResponded: true, + }).catch(err2 => console.error('[ia360-failure] slot confirm alert error:', err2.message)); } return; } @@ -7565,7 +7814,17 @@ function ia360BrainV2CanaryEligible(record) { if (!IA360_BRAIN_V2_CANARY_ON) return false; if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; if (!String(record.message_body || '').trim()) return false; - return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); + if (!IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number))) return false; + // G-LIVE (auditoría 06-11): el canary es un arnés del OWNER — todo su egress va + // hard-coded al owner. Un contacto real allowlisted por error quedaría 100% mudo + // (el route hace continue y el monolito nunca ve el mensaje). El invariante + // owner-only vive en código, no solo en el .env: un no-owner cae al monolito. + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.error('[brain-v2-canary] allowlist contiene un no-owner %s — cayendo al monolito', + maskIa360Number(record.contact_number)); + return false; + } + return true; } async function callBrainV2({ contactWaNumber, message, forceActor }) { @@ -7631,6 +7890,24 @@ async function handleBrainV2Canary(record) { console.log('[brain-v2-canary] sin reply (branch=%s)', branch); } +// G-LIVE QA-GUARD (incidente José Ramón): detecta payloads SINTÉTICOS del harness +// QA/E2E por su wamid (wamid.e2e.*, wamid.qa-harness.*, e2e.*, *harness*). Los wamid +// reales de Meta son 'wamid.' + base64 (sin más puntos), así que nunca matchean. +function isIa360SyntheticWamid(messageId) { + // Solo el patrón con puntos: el cuerpo base64 de un wamid real no contiene puntos, + // así que el falso positivo (descartar un mensaje real) es estructuralmente imposible. + return /(^|\.)(e2e|qa[-_a-z0-9]*|harness)\./i.test(String(messageId || '')); +} + +// Un payload sintético SOLO puede procesarse para números QA (52199900*) o el owner. +// Cualquier otro destino (contactos reales) se descarta completo: sin insert en +// chat_history, sin handlers, sin egress derivado. +function isIa360BlockedSyntheticInbound(record) { + if (!record || !isIa360SyntheticWamid(record.message_id)) return false; + const n = String(record.contact_number || ''); + return !(IA360_QA_NUMBER_RE.test(n) || n === IA360_OWNER_NUMBER); +} + router.post('/webhook/whatsapp', async (req, res) => { try { // Authenticity: this endpoint is necessarily unauthenticated (public), so @@ -7658,6 +7935,22 @@ router.post('/webhook/whatsapp', async (req, res) => { allRecords.push(...records); } + // G-LIVE QA-GUARD: descartar payloads sintéticos dirigidos a números reales + // ANTES de insertar o despachar nada (cierra el incidente José Ramón). + let blockedSynthetic = 0; + for (let i = allRecords.length - 1; i >= 0; i--) { + if (isIa360BlockedSyntheticInbound(allRecords[i])) { + const r = allRecords[i]; + console.warn('[qa-guard] payload sintético bloqueado: wamid=%s contact=%s type=%s', + r.message_id, maskIa360Number(r.contact_number), r.message_type); + allRecords.splice(i, 1); + blockedSynthetic += 1; + } + } + if (blockedSynthetic > 0 && allRecords.length === 0) { + return res.status(200).json({ ok: true, stored: 0, blocked_synthetic: blockedSynthetic }); + } + if (allRecords.length === 0) { // Acknowledge non-message webhooks (e.g. verification, errors) return res.status(200).json({ ok: true, stored: 0 }); @@ -7757,6 +8050,9 @@ router.post('/webhook/whatsapp', async (req, res) => { if (incomingRecords.length > 0) { for (const record of incomingRecords) { try { + // G-LIVE: invariante no-silencio. Se programa ANTES de cualquier handler + // para que cubra continues, throws y ramas fire-and-forget por igual. + scheduleIa360NoSilenceWatchdog(record); // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── // Va ANTES del canary Brain v2 (el owner está en la allowlist y el // canary haría continue). Captura, persiste y manda tarjeta de ruteo. @@ -7879,6 +8175,22 @@ router.post('/webhook/whatsapp', async (req, res) => { await evaluateTriggers(record); } catch (triggerErr) { console.error('[webhook] Trigger evaluation error:', triggerErr.message); + // G-LIVE (auditoría 06-11): un throw en el dispatch no debe dejar mudo al + // contacto. Verificamos la VERDAD en chat_history (no flags) antes del + // fallback; ante error del check, no doble-texteamos. + if (record.direction === 'incoming' + && ['text', 'interactive', 'button'].includes(record.message_type) + && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER + && !(record.message_type === 'text' && isIa360PassiveMessage(record.message_body))) { + const hasOut = await ia360HasOutgoingForInbound(record).catch(() => true); + if (!hasOut) { + await handleIa360BotFailure({ + record, + reason: 'error en dispatch: ' + String(triggerErr.message || 'desconocido').slice(0, 120), + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] dispatch fallback error:', e.message)); + } + } } } } @@ -7904,7 +8216,11 @@ router.post('/webhook/whatsapp', async (req, res) => { } console.log(`[webhook] Stored ${allRecords.length} record(s)`); - res.status(200).json({ ok: true, stored: allRecords.length }); + res.status(200).json({ + ok: true, + stored: allRecords.length, + ...(blockedSynthetic > 0 ? { blocked_synthetic: blockedSynthetic } : {}), + }); } catch (err) { console.error('[webhook] Error:', err.message); // Always return 200 to n8n so it doesn't retry infinitely. Use a static diff --git a/backend/test/ia360NoSilenceRegression.test.js b/backend/test/ia360NoSilenceRegression.test.js index 7085c51..7fde4a0 100644 --- a/backend/test/ia360NoSilenceRegression.test.js +++ b/backend/test/ia360NoSilenceRegression.test.js @@ -1,20 +1,105 @@ +// Regresión G-LIVE (P0 producción, 2026-06-11): contactos activos/beta SIEMPRE +// reciben respuesta real — nunca silencio, nunca inventar. Tests estáticos de +// patrones sobre webhook.js: cada uno protege un cierre del incidente Andrés +// (38 min mudo) y del incidente José Ramón (inyector QA a número real). const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'webhook.js'), 'utf8'); + test('cliente activo beta memory dry-run must fall through to no-silence fallback', () => { - const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'webhook.js'), 'utf8'); + // Rama dry-run legacy: persistir memoria jamás cuenta como respuesta. const dryRunStart = src.indexOf('if (!IA360_MEMORY_EGRESS_ON)'); const dryRunEnd = src.indexOf("await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply'", dryRunStart); - assert.notEqual(dryRunStart, -1, 'dry-run memory branch missing'); - assert.notEqual(dryRunEnd, -1, 'egress-on memory branch missing'); + assert.notEqual(dryRunStart, -1, 'falta la rama dry-run de memoria'); + assert.notEqual(dryRunEnd, -1, 'falta la rama egress-on de memoria'); const dryRunBlock = src.slice(dryRunStart, dryRunEnd); - assert.match(dryRunBlock, /egress=dry_run -> fallback_required/); - assert.match(dryRunBlock, /return false;/, 'dry-run memory learning is not a customer response'); + assert.match(dryRunBlock, /return false;/, 'el dry-run de memoria no es respuesta al cliente'); + + // captureOnly: la captura en segundo plano tampoco es respuesta. + const captureIdx = src.indexOf('if (captureOnly)'); + assert.notEqual(captureIdx, -1, 'falta la rama captureOnly del learning'); + const captureBlock = src.slice(captureIdx, src.indexOf('const reply = buildIa360ClienteActivoBetaReply', captureIdx)); + assert.match(captureBlock, /egress=capture_only/); + assert.match(captureBlock, /return false;/, 'captureOnly debe devolver false siempre'); + + // La rama cliente activo/beta del padre responde DE VERDAD (agente) o cae a failure. + const betaStart = src.indexOf("if (deal.memory_mode === 'cliente_activo_beta_supervisado'"); + const betaEnd = src.indexOf('const agent = await callIa360Agent', betaStart); + assert.notEqual(betaStart, -1, 'falta la rama cliente activo/beta en handleIa360FreeText'); + assert.notEqual(betaEnd, -1, 'falta la rama 100M del agente'); + const betaBlock = src.slice(betaStart, betaEnd); + assert.match(betaBlock, /captureOnly: true/, 'la rama beta debe capturar memoria sin responder'); + assert.match(betaBlock, /callIa360Agent/, 'la rama beta debe pedir respuesta real al agente'); + assert.match(betaBlock, /ia360_cliente_activo_beta_agent_reply/, 'la respuesta real debe encolar al contacto'); + assert.match(betaBlock, /roleHint: buildIa360ClienteActivoBetaRoleHint/, 'el agente debe recibir el perfil ejecutivo'); + assert.match(betaBlock, /handleIa360BotFailure/, 'sin agente debe haber holding + alerta + failure'); + assert.match(betaBlock, /agente IA sin respuesta utilizable/); +}); + +test('invariante no-silencio: watchdog estructural en el dispatcher', () => { + assert.match(src, /IA360_NO_SILENCE_WATCHDOG_MS/, 'falta la constante del watchdog'); + assert.match(src, /function scheduleIa360NoSilenceWatchdog\(record\)/); + assert.match(src, /async function ia360NoSilenceWatchdogCheck\(record\)/); + assert.match(src, /async function ia360HasOutgoingForInbound\(record\)/); + + // Se programa dentro del loop de incoming ANTES de cualquier handler (cubre + // continues, throws y ramas fire-and-forget por igual). + const loopIdx = src.indexOf('for (const record of incomingRecords)'); + assert.notEqual(loopIdx, -1); + const watchdogCall = src.indexOf('scheduleIa360NoSilenceWatchdog(record);', loopIdx); + const firstHandler = src.indexOf('handleIa360OwnerIdeaCommand', loopIdx); + assert.notEqual(watchdogCall, -1, 'el watchdog no se programa en el dispatcher'); + assert.ok(watchdogCall < firstHandler, 'el watchdog debe programarse antes de cualquier handler'); + + // El check consulta la VERDAD en chat_history (fila outgoing), no flags en memoria. + const checkBlock = src.slice( + src.indexOf('async function ia360HasOutgoingForInbound'), + src.indexOf('async function ia360IsWatchedActiveContact') + ); + assert.match(checkBlock, /direction = 'outgoing'/); + assert.match(checkBlock, /ia360_handler_for/); + + // El disparo termina en holding + alerta + failure. + const fireBlock = src.slice( + src.indexOf('async function ia360NoSilenceWatchdogCheck'), + src.indexOf('function scheduleIa360NoSilenceWatchdog') + ); + assert.match(fireBlock, /handleIa360BotFailure/); + assert.match(fireBlock, /invariante no-silencio/); +}); + +test('qa-guard: payload sintético jamás egresa a números reales', () => { + assert.match(src, /function isIa360SyntheticWamid\(messageId\)/); + assert.match(src, /function isIa360BlockedSyntheticInbound\(record\)/); + const guardFn = src.slice( + src.indexOf('function isIa360BlockedSyntheticInbound'), + src.indexOf("router.post('/webhook/whatsapp'") + ); + assert.match(guardFn, /IA360_QA_NUMBER_RE/, 'el guard debe permitir solo números QA'); + assert.match(guardFn, /IA360_OWNER_NUMBER/, 'el guard debe permitir al owner'); + + // El filtro corre en el POST antes del INSERT a chat_history (sin insert, sin + // handlers, sin egress derivado — cierre del incidente José Ramón). + const postIdx = src.indexOf("router.post('/webhook/whatsapp'"); + const guardIdx = src.indexOf('isIa360BlockedSyntheticInbound(allRecords[i])', postIdx); + const insertIdx = src.indexOf('INSERT INTO coexistence.chat_history', postIdx); + assert.notEqual(guardIdx, -1, 'el guard sintético no se aplica en el POST'); + assert.ok(guardIdx < insertIdx, 'el guard debe correr antes del INSERT a chat_history'); + + // Allowlist QA correcta: longitud exacta (no '\d*' laxo que admite móviles reales). + assert.match(src, /\^52199900\\d\{5\}\$/); +}); - const parentFallback = src.slice(src.indexOf('async function handleIa360FreeText'), src.indexOf('const agent = await callIa360Agent')); - assert.match(parentFallback, /handleIa360BotFailure/); - assert.match(parentFallback, /cliente activo\/beta sin contexto suficiente/); +test('datos vivos del portal: handoff explícito, nunca inventar listas', () => { + assert.match(src, /function isIa360PortalLiveDataQuestion\(body\)/); + assert.match(src, /ia360_cliente_activo_portal_handoff/); + // El handoff alerta al owner sin doble-textear al contacto, pero solo afirma + // "ya respondido" si el envío del handoff realmente se encoló. + const handoffIdx = src.indexOf('ia360_cliente_activo_portal_handoff'); + const handoffBlock = src.slice(handoffIdx, handoffIdx + 900); + assert.match(handoffBlock, /alreadyResponded: sentHandoff === true/); }); diff --git a/glive-e2e.sh b/glive-e2e.sh new file mode 100755 index 0000000..c71667b --- /dev/null +++ b/glive-e2e.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E G-LIVE — no-silencio producción contactos activos/beta (2026-06-11) +# SIM 1 — Respuesta real: QA beta pregunta sustantiva → reply del agente IA +# (label ia360_cliente_activo_beta_agent_reply) en chat_history. +# SIM 2 — Agente caído ([qa-force-agent-down]) → holding al contacto + alerta +# al owner + fila en ia360_bot_failures. +# SIM 3 — Guard inyector: wamid e2e.* hacia un número real no-QA → bloqueado +# completo (0 filas, 0 egress, blocked_synthetic en la respuesta). +# SIM 4 — Invariante de clase (watchdog): audio de QA beta sin handler → a los +# 75 s holding + failure 'invariante no-silencio'. +# Uso: bash glive-e2e.sh (correr en el VPS). Solo números QA + owner. +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="873315362541590" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" +QA="5219990000950" # QA cliente activo/beta (creado en STEP 0) +FAKE_REAL="5213399999999" # número NO-QA inventado: el guard lo descarta antes de todo + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado='$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "$2" "contiene:$3"; fi; } +chk_nonempty(){ if [ -n "$2" ]; then ok "$1"; else bad "$1" "(vacío)" "no-vacío"; fi; } + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status)+" "+t);}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } + +text_payload(){ # $1=from $2=wamid $3=texto $4=nombre + printf '{"object":"whatsapp_business_account","entry":[{"id":"WABA","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"%s","phone_number_id":"%s"},"contacts":[{"wa_id":"%s","profile":{"name":"%s"}}],"messages":[{"from":"%s","id":"%s","timestamp":"%s","type":"text","text":{"body":"%s"}}]}}]}]}' \ + "$WA" "$PID_NUM" "$1" "$4" "$1" "$2" "$(ts)" "$3" +} + +audio_payload(){ # $1=from $2=wamid + printf '{"object":"whatsapp_business_account","entry":[{"id":"WABA","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"%s","phone_number_id":"%s"},"contacts":[{"wa_id":"%s","profile":{"name":"QA Cliente Activo Beta"}}],"messages":[{"from":"%s","id":"%s","timestamp":"%s","type":"audio","audio":{"id":"fake-media-glive","mime_type":"audio/ogg; codecs=opus","voice":true}}]}}]}]}' \ + "$WA" "$PID_NUM" "$1" "$1" "$2" "$(ts)" +} + +wait_out_label(){ # $1=contacto $2=label $3=timeout_s → message_body + local deadline=$(( $(date +%s) + ${3:-90} )); local body="" + while [ "$(date +%s)" -lt "$deadline" ]; do + body=$(psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' AND template_meta->>'label'='$2' ORDER BY id DESC LIMIT 1") + [ -n "$body" ] && { printf '%s' "$body"; return 0; } + sleep 5 + done + printf '%s' "" +} + +echo "=== STEP 0 — preparar contacto QA cliente activo/beta ($QA) ===" +psql_q "INSERT INTO coexistence.contacts (wa_number, contact_number, name, tags, custom_fields) + VALUES ('$WA', '$QA', 'QA Cliente Activo Beta', + '[\"cliente-activo-beta\",\"staged\"]'::jsonb, + '{\"staged\": true, \"ia360_cliente_activo_beta\": {\"schema\": \"cliente_activo_beta.v1\", \"contact_role\": \"Director de Finanzas (QA)\", \"project\": \"Camiones Selectos QA\", \"do_not_pitch\": true}}'::jsonb) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = EXCLUDED.name, + tags = EXCLUDED.tags, + custom_fields = coexistence.contacts.custom_fields || EXCLUDED.custom_fields" >/dev/null +chk "contacto QA beta existe" "$(psql_q "SELECT count(*) FROM coexistence.contacts WHERE contact_number='$QA' AND tags ? 'cliente-activo-beta'")" "1" +# Limpieza de corridas previas (solo el QA glive) +psql_q "DELETE FROM coexistence.chat_history WHERE contact_number='$QA'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_bot_failures WHERE contact_number='$QA'" >/dev/null + +echo "=== SIM 1 — pregunta sustantiva → respuesta REAL del agente ===" +W1="wamid.e2e.glive.real.$(ts).$RANDOM" +Q1="Oye, antes de subir nuestra información de clientes, ¿qué control tenemos sobre quién puede ver esos datos y qué riesgo hay si algo sale mal?" +ST=$(post_webhook "$(text_payload "$QA" "$W1" "$Q1" "QA Cliente Activo Beta")") +chk_has "SIM1 webhook 200" "$ST" "200" +R1=$(wait_out_label "$QA" "ia360_cliente_activo_beta_agent_reply" 90) +chk_nonempty "SIM1 reply real del agente en chat_history" "$R1" +echo " reply: $(echo "$R1" | head -c 220)" +ROW1=$(psql_q "SELECT id||' | '||direction||' | '||status||' | '||LEFT(message_body,120) FROM coexistence.chat_history WHERE contact_number='$QA' AND template_meta->>'label'='ia360_cliente_activo_beta_agent_reply' ORDER BY id DESC LIMIT 1") +echo " fila: $ROW1" + +echo "=== SIM 2 — agente caído → holding + alerta + failure ===" +W2="wamid.e2e.glive.down.$(ts).$RANDOM" +Q2="[qa-force-agent-down] Necesito saber si nuestros datos del proyecto quedaron respaldados esta semana." +ST=$(post_webhook "$(text_payload "$QA" "$W2" "$Q2" "QA Cliente Activo Beta")") +chk_has "SIM2 webhook 200" "$ST" "200" +H2=$(wait_out_label "$QA" "ia360_fallback_no_silence" 60) +chk_nonempty "SIM2 holding al contacto" "$H2" +F2=$(psql_q "SELECT id||' | '||status||' | '||reason FROM coexistence.ia360_bot_failures WHERE contact_number='$QA' AND reason LIKE 'cliente activo/beta: agente IA%' ORDER BY id DESC LIMIT 1") +chk_nonempty "SIM2 failure registrado" "$F2" +echo " failure: $F2" +A2=$(psql_q "SELECT LEFT(message_body,100) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='owner_bot_failure' AND message_body LIKE '%$QA%' ORDER BY id DESC LIMIT 1") +chk_nonempty "SIM2 alerta al owner" "$A2" + +echo "=== SIM 3 — guard del inyector: sintético a número real → bloqueado ===" +W3="wamid.e2e.glive.guard.$(ts).$RANDOM" +ST3=$(post_webhook "$(text_payload "$FAKE_REAL" "$W3" "Hola, me interesa" "Contacto Real Falso")") +chk_has "SIM3 webhook responde blocked_synthetic" "$ST3" "blocked_synthetic" +chk "SIM3 cero filas chat_history del número real" "$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE contact_number='$FAKE_REAL'")" "0" +chk "SIM3 cero egress al número real" "$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND (contact_number='$FAKE_REAL' OR to_number='$FAKE_REAL')")" "0" + +echo "=== SIM 4 — invariante de clase: audio sin handler → watchdog a los 75 s ===" +W4="wamid.e2e.glive.audio.$(ts).$RANDOM" +ST=$(post_webhook "$(audio_payload "$QA" "$W4")") +chk_has "SIM4 webhook 200" "$ST" "200" +echo " esperando ventana del watchdog (90 s)..." +sleep 90 +F4=$(psql_q "SELECT id||' | '||status||' | '||LEFT(reason,90) FROM coexistence.ia360_bot_failures WHERE contact_number='$QA' AND reason LIKE 'invariante no-silencio%' ORDER BY id DESC LIMIT 1") +chk_nonempty "SIM4 watchdog disparó failure" "$F4" +echo " failure: $F4" +H4=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND template_meta->>'label'='ia360_fallback_no_silence'") +if [ "$H4" -ge 2 ]; then ok "SIM4 holding del watchdog al contacto (total fallbacks=$H4)"; else bad "SIM4 holding del watchdog" "$H4" ">=2"; fi + +echo "" +echo "=== RESULTADO: PASS=$PASS FAIL=$FAIL ===" +[ "$FAIL" -eq 0 ] && echo "G-LIVE E2E: TODO VERDE" || echo "G-LIVE E2E: HAY FALLAS" +exit $FAIL From 38fe3e81f66cccf4fa760e4236c5a911cd11eecf Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Thu, 11 Jun 2026 23:03:27 +0000 Subject: [PATCH 29/39] =?UTF-8?q?feat(ia360):=20G-BRAIN=20=E2=80=94=20conv?= =?UTF-8?q?ersaci=C3=B3n=20con=20memoria=20y=20cierre=20+=20dedupe=20at?= =?UTF-8?q?=C3=B3mico=20+=20sandbox=20QA=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dedupe: índice único parcial chat_history_ia360_handler_dedupe + ON CONFLICT DO NOTHING en insertPendingRow (TOCTOU cerrado); candado anti-doble-ruta enqueueIa360AgentReply (advisory xact lock por contacto) — dos inbound casi simultáneos ya no generan dos respuestas (caso José Ramón ids 1651/1653). - Estado conversacional ia360_conv_state en custom_fields + roleHint extendido: presupuesto 4 preguntas/conversación (1 por turno), prohibido re-preguntar lo respondido, correcciones respetadas, cierre con 2-3 opciones cada 3 turnos, señal de compra => proponer, modo PROPONER desde "Dolor calificado"; facts de memory-lookup inyectados al agente vía roleHint (n8n intacto). - Sandbox QA: egress a 52199900XXXXX cortado en sendQueue antes de Meta (wamid qa-sandbox, harness verde sin riesgo de calidad); tarjetas/readouts al owner con origen wamid sintético quedan status=qa_sandboxed sin egress real; alertas owner_bot_failure de contactos QA van a ia360_qa_evidence; deny synthetic_approve_real_contact en approve-send. OJO review: patrón QA exacto, NUNCA ^521999 (lada de Mérida). - Render: message_body de templates persiste el body RENDERIZADO con vars (el {{1}} literal del id 1612 era solo logging; el payload a Graph iba correcto). - Deudas cerradas: dedupSuffix en enqueueIa360Text, post-filtro pitch do_not_pitch, last_reply_kind no se escribe en captureOnly, beta con deal lost recibe agente; sendIa360DirectText incluye label en el handler. - DB: 9 duplicados históricos sufijados :dup-N, índice único, tabla ia360_qa_evidence, constraint de status admite qa_sandboxed. - E2E: gbrain-e2e.sh nuevo 16/16 (facts reflejados, corrección, cierre, dedupe 2=>1, sandbox); regresión glive 14/14; gcold 62/62 con fixture SUBMITTED + filtro temporal anti falsos PASS. Co-Authored-By: Claude Fable 5 --- backend/src/queue/sendQueue.js | 15 + backend/src/routes/webhook.js | 458 ++++++++++++++++++++++++-- backend/src/services/messageSender.js | 29 +- gbrain-e2e.sh | 162 +++++++++ gcold-e2e.sh | 33 +- glive-e2e.sh | 8 +- 6 files changed, 665 insertions(+), 40 deletions(-) create mode 100644 gbrain-e2e.sh diff --git a/backend/src/queue/sendQueue.js b/backend/src/queue/sendQueue.js index 0dc18fa..3f0851b 100644 --- a/backend/src/queue/sendQueue.js +++ b/backend/src/queue/sendQueue.js @@ -77,6 +77,21 @@ async function processJob(job) { } } + // G-BRAIN sandbox QA: los números 52199900XXXXX son ficticios (harness E2E). + // Enviarlos por el WABA real es riesgo de calidad ante Meta (decenas de envíos + // a números inexistentes). Tras pasar la validación de template (fidelidad del + // test), se corta AQUÍ — el único choke point de egress — sin llamar a Meta: la + // fila optimista se marca 'sent' con un wamid sintético, así los harness que + // esperan status='sent' siguen en verde con cero egress real. + // OJO (hallazgo de review): el patrón DEBE ser el rango QA exacto. 999 es la + // lada de Mérida — un patrón ancho tipo ^521999 dejaría mudo a un cliente real. + if (/^52199900\d{5}$/.test(String(to || ''))) { + const sandboxWamid = `wamid.qa-sandbox.${Date.now()}.${job.id || '0'}`; + if (localMessageId) await markSent(localMessageId, sandboxWamid); + console.log('[sendQueue] QA sandbox: egress a Meta OMITIDO kind=%s to=%s wamid=%s', kind, to, sandboxWamid); + return { wamid: sandboxWamid, qaSandbox: true }; + } + let result; try { if (kind === 'text') { diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 8aed475..d0b3947 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -367,20 +367,162 @@ function isIa360PortalLiveDataQuestion(body) { // G-LIVE: perfil de comunicación para el agente IA. Viaja como `role` en el payload; // el nodo Normalize del workflow n8n lo expone al modelo como contact.role (sin tocar // n8n publicado). do_not_pitch => tono ejecutivo: corto, negocio, control/riesgo. -function buildIa360ClienteActivoBetaRoleHint(contact) { +function isIa360DoNotPitchContact(contact) { const cf = contact?.custom_fields || {}; const beta = cf.ia360_cliente_activo_beta || {}; const role = beta.contact_role || cf.project_role || cf.rol_comite || 'cliente activo beta'; const noPitchRaw = beta.do_not_pitch != null ? beta.do_not_pitch : cf.do_not_pitch; - const noPitch = noPitchRaw === true || noPitchRaw === 1 + return noPitchRaw === true || noPitchRaw === 1 || /^(true|1|s[ií]|yes)$/i.test(String(noPitchRaw || '')) || /cfo|champion|finanzas/i.test(String(role)); +} + +function buildIa360ClienteActivoBetaRoleHint(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const role = beta.contact_role || cf.project_role || cf.rol_comite || 'cliente activo beta'; + const noPitch = isIa360DoNotPitchContact(contact); const parts = [String(role), 'cliente activo en beta supervisada']; if (noPitch) parts.push('do_not_pitch: PROHIBIDO vender, pitchear u ofrecer nuevos servicios'); parts.push('estilo ejecutivo: respuesta corta, implicación de negocio, control y riesgo, sin detalle técnico salvo que lo pida'); return parts.join(' | '); } +// ── G-BRAIN: estado conversacional del agente por contacto ───────────────────── +// El agente excavaba sin memoria de turno (caso José Ramón 06-11: 8 preguntas +// seguidas, ninguna propuesta, re-preguntas de lo ya respondido). El estado vive +// en custom_fields.ia360_conv_state y entra al agente vía roleHint (mecanismo +// G-LIVE: Normalize lo mapea a contact.role en el agent_input, SIN tocar n8n). +const IA360_CONV_QUESTION_BUDGET = 4; // preguntas máximas de diagnóstico por conversación +const IA360_CONV_OPTIONS_EVERY = 3; // cada N turnos del agente: resumen + opciones + +function loadIa360ConvState(contact) { + const raw = contact?.custom_fields?.ia360_conv_state; + const st = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw : {}; + return { + turns: Number(st.turns) || 0, + asked_total: Number(st.asked_total) || 0, + questions_asked: Array.isArray(st.questions_asked) ? st.questions_asked : [], + answered: (st.answered && typeof st.answered === 'object' && !Array.isArray(st.answered)) ? st.answered : {}, + corrections: Array.isArray(st.corrections) ? st.corrections : [], + last_options_turn: Number(st.last_options_turn) || 0, + }; +} + +// Oraciones interrogativas del reply del agente (terminadas en '?'), compactas. +function extractIa360Questions(text) { + return String(text || '') + .split(/(?<=[?.!\n])/) + .map(s => s.trim()) + .filter(s => s.endsWith('?')) + .map(s => (s.length > 160 ? s.slice(0, 157) + '...' : s)); +} + +// Señal de corrección del contacto ("el problema sí es CRM porque no se usa +// ninguno"): el agente debe respetarla y nunca volver al dato corregido. +function detectIa360Correction(body) { + return /(no,?\s+(es|era|así)|me refer[ií]|en realidad|m[aá]s bien|te corrijo|no me refiero|no es eso|el problema\s+(s[ií]|si)\s+es)/i.test(String(body || '')); +} + +// Señal de compra o de carencia explícita: el siguiente mensaje propone, no pregunta. +function detectIa360BuyingSignal(body) { + return /(no (tenemos|hay|contamos con|usamos)\s|nos (falta|urge|interesa)|c[oó]mo le (hacemos|hago)|qu[eé] (sigue|necesitas|propones)|me interesa|cu[aá]nto (cuesta|sale)|d[oó]nde (firmo|empiezo)|ay[uú]dame con)/i.test(String(body || '')); +} + +// "Dolor calificado" en adelante: el agente deja de excavar y PROPONE. +function ia360StageMode(stageName) { + return ['Dolor calificado', 'Agenda en proceso', 'Reunión agendada', 'Requiere Alek'].includes(String(stageName || '')) + ? 'proponer' : 'excavar'; +} + +function buildIa360ConversationRoleHint({ contact, stageName, convState, memory, messageBody = '' }) { + const st = convState || loadIa360ConvState(contact); + const profile = getIa360ContactProfile(contact); + const parts = []; + if (profile.name) parts.push(`hablas con ${profile.name}${profile.role ? ` (${profile.role})` : ''}${profile.accountName ? ` de ${profile.accountName}` : ''}`); + + // Memoria del contacto (memory-lookup): el modelo DEBE usarla, no re-preguntarla. + const factBits = []; + for (const f of (memory?.facts || []).slice(0, 4)) { + const bit = [f.recurring_pain, f.preference, f.objection].filter(Boolean).join('; '); + if (bit) factBits.push(bit); + } + for (const e of (memory?.events || []).slice(0, 2)) { + if (e.summary) factBits.push(String(e.summary)); + } + if (factBits.length) parts.push(`contexto que YA SABES del contacto (úsalo en tu respuesta, no preguntes lo que ya sabes): ${factBits.slice(0, 5).map(b => String(b).slice(0, 140)).join(' / ')}`); + + // Estado de ESTA conversación. + const learned = Object.entries(st.answered).filter(([, v]) => v != null && String(v).trim() !== '') + .map(([k, v]) => `${k}=${String(v).slice(0, 80)}`); + if (learned.length) parts.push(`el contacto YA respondió: ${learned.join(', ')} — PROHIBIDO volver a preguntarlo`); + if (st.corrections.length) parts.push(`correcciones del contacto que DEBES respetar: ${st.corrections.slice(-2).map(c => `«${String(c).slice(0, 110)}»`).join(' ')}`); + if (st.questions_asked.length) parts.push(`preguntas que YA hiciste (no las repitas ni las reformules): ${st.questions_asked.slice(-4).map(q => `«${q}»`).join(' ')}`); + + // Modo por etapa. + if (ia360StageMode(stageName) === 'proponer') { + parts.push(`etapa "${stageName}": MODO PROPONER — deja de excavar; resume el dolor en 1-2 líneas y propone 2-3 acciones concretas (resumen del mapa de su operación, llamada con Alek, mini-plan inicial); si preguntas, que sea SOLO para elegir entre esas opciones`); + } else { + parts.push(`etapa "${stageName || 'inicio'}": puedes explorar el dolor, con presupuesto de preguntas`); + } + + // Presupuesto de preguntas. + if (st.asked_total >= IA360_CONV_QUESTION_BUDGET) { + parts.push(`presupuesto de preguntas AGOTADO (${st.asked_total}/${IA360_CONV_QUESTION_BUDGET}): NO hagas más preguntas de diagnóstico; resume lo aprendido y propone 2-3 siguientes pasos concretos`); + } else { + parts.push(`presupuesto de preguntas: ${st.asked_total}/${IA360_CONV_QUESTION_BUDGET} usadas en la conversación; máximo UNA pregunta por mensaje, nunca dos`); + } + + // Cadencia de cierre con opciones. + if (st.turns > 0 && (st.turns - st.last_options_turn) >= IA360_CONV_OPTIONS_EVERY) { + parts.push('en ESTE turno toca cierre: resume en 2-3 líneas lo aprendido hasta ahora y ofrece 2-3 opciones de siguiente paso (resumen del mapa, llamada con Alek, mini-plan), no otra pregunta abierta'); + } + if (detectIa360BuyingSignal(messageBody)) { + parts.push('el último mensaje trae SEÑAL DE COMPRA o carencia explícita: responde proponiendo el siguiente paso concreto, NO con otra pregunta de diagnóstico'); + } + parts.push('si dejaste una pregunta abierta y el contacto no la respondió, retómala o ciérrala explícitamente; nunca la abandones en silencio'); + return parts.join(' | '); +} + +// Persistencia del estado tras CADA reply conversacional enviado. +async function updateIa360ConvState({ record, contact, agent, replyBody, convState }) { + try { + const st = convState || loadIa360ConvState(contact); + const newQuestions = extractIa360Questions(replyBody); + const answered = { ...st.answered }; + for (const [k, v] of Object.entries(agent?.extracted || {})) { + if (v != null && String(v).trim() !== '') answered[k] = String(v).slice(0, 120); + } + const corrections = [...st.corrections]; + if (detectIa360Correction(record.message_body)) corrections.push(String(record.message_body).slice(0, 140)); + const turns = st.turns + 1; + const dueOptions = st.turns > 0 && (st.turns - st.last_options_turn) >= IA360_CONV_OPTIONS_EVERY; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_conv_state: { + turns, + asked_total: st.asked_total + newQuestions.length, + questions_asked: [...st.questions_asked, ...newQuestions].slice(-8), + answered, + corrections: corrections.slice(-4), + // El hint exige el cierre cuando toca; el turno que lo pidió resetea la cadencia. + last_options_turn: dueOptions ? turns : st.last_options_turn, + updated_at: new Date().toISOString(), + }, + }, + }); + } catch (e) { + console.error('[ia360-conv-state] update error:', e.message); + } +} + +// Deuda G-LIVE: post-filtro de pitch en CÓDIGO (no solo en prompt) para do_not_pitch. +function ia360ReplySmellsLikePitch(text) { + return /(contrat(a|ar|aci[oó]n)|cotizaci[oó]n|precio especial|descuento|promoci[oó]n|plan (mensual|anual)|propuesta comercial|te (vendo|ofrezco el (servicio|plan))|upgrade|upsell)/i.test(String(text || '')); +} + function getIa360ContactProfile(contact) { const cf = contact?.custom_fields || {}; const beta = cf.ia360_cliente_activo_beta || {}; @@ -775,7 +917,9 @@ async function handleIa360ClienteActivoBetaLearning({ record, deal, contact, cap ia360_memory_last_event_at: new Date().toISOString(), ia360_memory_last_areas: signals.map(s => s.area), ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, - ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + // Deuda G-LIVE cerrada: en captureOnly NO hubo reply — escribir el marcador + // de "última respuesta" contaminaba el estado informativo del contacto. + ...(captureOnly ? {} : { ia360_cliente_activo_beta_last_reply_kind: 'memory_learning' }), }, }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); if (captureOnly) { @@ -1812,6 +1956,9 @@ async function enqueueIa360Interactive({ record, label, messageBody, interactive if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); } catch (e) { /* keep label-only body on any parsing issue */ } + // G-BRAIN sandbox: interactivo al OWNER originado por inbound sintético del + // harness => fila 'qa_sandboxed' sin egress real. + const qaSandbox = normalizePhone(record.contact_number) === IA360_OWNER_NUMBER && isIa360SyntheticWamid(record.message_id); const localId = await insertPendingRow({ account, toNumber: record.contact_number, @@ -1822,9 +1969,16 @@ async function enqueueIa360Interactive({ record, label, messageBody, interactive label, ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, source: 'webhook_interactive_reply', + ...(qaSandbox ? { qa_sandbox: true } : {}), }, rawPayloadExtra: interactive, + ...(qaSandbox ? { status: 'qa_sandboxed' } : {}), }); + if (!localId) return false; // dedupe atómico ON CONFLICT: otra ruta ya insertó este handler + if (qaSandbox) { + console.log('[ia360-qa-sandbox] interactivo al owner SIN egress real label=%s origen=%s', label, record.message_id); + return true; + } await enqueueSend({ kind: 'interactive', accountId: account.id, @@ -1897,11 +2051,17 @@ async function dispatchContextFlow(record) { }); } -async function enqueueIa360Text({ record, label, body }) { - const resolved = await resolveIa360Outbound(record); +async function enqueueIa360Text({ record, label, body, dedupSuffix = '' }) { + // dedupSuffix (deuda G-LIVE): permite un SEGUNDO texto legítimo al mismo contacto + // para el mismo inbound (p.ej. fallback del slot-confirm tras un prompt fallido) + // sin que el dedupe por ia360_handler_for lo descarte. Default '' = sin cambio. + const resolved = await resolveIa360Outbound(record, dedupSuffix); if (resolved.duplicate || resolved.error) return false; const { account } = resolved; + // G-BRAIN sandbox: texto al OWNER originado por inbound sintético del harness + // => fila 'qa_sandboxed' sin egress real. + const qaSandbox = normalizePhone(record.contact_number) === IA360_OWNER_NUMBER && isIa360SyntheticWamid(record.message_id); const localId = await insertPendingRow({ account, toNumber: record.contact_number, @@ -1910,10 +2070,86 @@ async function enqueueIa360Text({ record, label, body }) { templateMeta: { ux: 'ia360_100m', label, - ia360_handler_for: record.message_id, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, source: 'webhook_terminal_handoff', + ...(qaSandbox ? { qa_sandbox: true } : {}), }, + ...(qaSandbox ? { status: 'qa_sandboxed' } : {}), }); + if (!localId) return false; // dedupe atómico ON CONFLICT: otra ruta ya insertó este handler + if (qaSandbox) { + console.log('[ia360-qa-sandbox] texto al owner SIN egress real label=%s origen=%s', label, record.message_id); + return true; + } + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +// G-BRAIN: respuesta conversacional del AGENTE con candado anti-doble-ruta. +// El incidente José Ramón (ids 1651/1653): dos inbound casi simultáneos del mismo +// contacto (texto + botón) dispararon rutas paralelas — la seq_* contestó al +// instante y el agente (latencia 5-30 s) contestó LO MISMO 14 s después. El candado: +// dentro de UNA transacción con advisory lock por contacto, si ya existe una +// respuesta conversacional INSERTADA DESPUÉS de este inbound (o.id > inbound.id, +// ventana 120 s), el reply del agente se descarta. El INSERT de la fila pendiente +// ocurre en la MISMA transacción (no SELECT-then-INSERT): dos agentes concurrentes +// se serializan en el lock y el segundo ve la fila del primero. +// Devuelve: true = enviado; 'superseded' (truthy) = otra ruta ya contestó, el +// caller debe marcar responded sin alertar; false = error/dedupe duro. +async function enqueueIa360AgentReply({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + const client = await pool.connect(); + let localId = null; + try { + await client.query('BEGIN'); + await client.query('SELECT pg_advisory_xact_lock(73601, hashtext($1))', [String(record.contact_number || '')]); + const { rows } = await client.query( + `SELECT o.id FROM coexistence.chat_history o + WHERE o.direction = 'outgoing' + AND o.contact_number = $1 + AND o.template_meta->>'source' IN ('webhook_terminal_handoff', 'webhook_interactive_reply') + AND COALESCE(o.status, '') NOT IN ('failed', 'error') + AND o.id > COALESCE((SELECT i.id FROM coexistence.chat_history i WHERE i.message_id = $2 LIMIT 1), 0) + AND o.created_at > NOW() - INTERVAL '120 seconds' + LIMIT 1`, + [record.contact_number, record.message_id || '~none~'] + ); + if (rows.length) { + await client.query('ROLLBACK'); + console.log('[ia360-dedupe] reply del agente DESCARTADO: otra ruta ya contestó (contact=%s inbound=%s outgoing_id=%s)', + maskIa360Number(record.contact_number), record.message_id, rows[0].id); + return 'superseded'; + } + localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + db: client, + }); + await client.query(localId ? 'COMMIT' : 'ROLLBACK'); + } catch (err) { + try { await client.query('ROLLBACK'); } catch (_) { /* ya cerrada */ } + console.error('[ia360-dedupe] agent reply tx error:', err.message); + return false; + } finally { + client.release(); + } + if (!localId) return false; await enqueueSend({ kind: 'text', accountId: account.id, @@ -1975,11 +2211,19 @@ async function resolveTemplateHeaderMediaId(tpl, account) { const { validateTemplateSend } = require('../integrations/templateValidator'); // Render a template body to plain text using the same param logic as -// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). -function renderIa360TemplateBody(tpl, record) { +// buildIa360TemplateComponents ({{1}} = contact first name, {{n}} = vars[n] reales +// del flujo si traen contenido, si no samples[n]). Mantenerlo en paridad con +// buildIa360TemplateComponents: este render es lo que se persiste como +// message_body para que la auditoría del owner vea EXACTAMENTE lo que recibió +// el contacto, no el body crudo con {{1}} (gap de auditoría del id 1612). +function renderIa360TemplateBody(tpl, record, vars = null) { const samples = parseTemplateSamples(tpl.samples); - return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => - k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => { + if (k === '1') return firstNameForTemplate(record); + const v = vars?.[k]; + const hasVar = v != null && String(v).trim() !== ''; + return hasVar ? String(v) : String(samples[k] || ' '); + }); } async function buildIa360TemplateComponents(tpl, account, record, vars = null) { @@ -2085,7 +2329,7 @@ async function enqueueIa360Template({ record, label, templateName, templateId = return { ok: false, status: 'template_invalid', error: v.errors.join('; ') }; } console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); - const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record, vars) }); return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; } } catch (err) { @@ -2096,7 +2340,10 @@ async function enqueueIa360Template({ record, label, templateName, templateId = account, toNumber: record.contact_number, messageType: 'template', - messageBody: tpl.body || tpl.name, + // G-BRAIN: persistir el body RENDERIZADO (mismos parámetros que los components + // enviados a Graph), no el crudo con {{1}} — la fila de chat_history es la + // evidencia de auditoría de lo que el contacto recibió de verdad. + messageBody: renderIa360TemplateBody(tpl, record, vars) || tpl.name, templateMeta: { ux: 'ia360_owner_pipeline', label, @@ -2108,6 +2355,7 @@ async function enqueueIa360Template({ record, label, templateName, templateId = header_media_library_id: tpl.header_media_library_id || null, }, }); + if (!localId) return { ok: false, status: 'duplicate', error: null }; // dedupe atómico ON CONFLICT await enqueueSend({ kind: 'template', accountId: account.id, @@ -2310,7 +2558,10 @@ async function getActiveNonTerminalIa360Deal(record) { // is the exception: it can learn/respond with delight guardrails, not sell. if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { const contact = await loadIa360ContactContext(record).catch(() => null); - if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + // Deuda G-LIVE cerrada: la marca beta en contacts MANDA aunque el último deal + // sea lost — un cliente activo/beta con un deal viejo perdido seguía recibiendo + // solo el holding del watchdog en vez de respuesta real del agente. + if (isIa360ClienteActivoBetaContact(contact)) { return { ...deal, stage_name: 'Cliente activo beta supervisado', @@ -2366,7 +2617,7 @@ async function shadowIa360ContactIntelligence({ record, stageName, history }) { } } -async function callIa360Agent({ record, stageName, roleHint = null }) { +async function callIa360Agent({ record, stageName, roleHint = null, memory = null }) { // Recent conversation for context (last 8 messages). const { rows: hist } = await pool.query( `SELECT direction AS dir, message_body AS body @@ -2379,12 +2630,16 @@ async function callIa360Agent({ record, stageName, roleHint = null }) { // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda // con contexto real del negocio del contacto, no en frio. Best-effort. - let agentMemory = null; - try { - const memContact = await loadIa360ContactContext(record); - agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); - } catch (memErr) { - console.error('[ia360-agent] memory lookup failed:', memErr.message); + // G-BRAIN: el caller puede pasarla precargada (memory) para no duplicar la + // consulta cuando ya la usó para construir el roleHint. + let agentMemory = memory; + if (!agentMemory) { + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } } const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; @@ -4367,6 +4622,14 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } } } + // G-BRAIN: un tap de aprobación SINTÉTICO (harness E2E) solo puede disparar + // envíos a números QA o al owner. Un harness con un vCard de contacto REAL + // (clase incidente José Ramón) se detiene aquí, en el último metro. + if (isIa360SyntheticWamid(record.message_id) + && !(IA360_QA_NUMBER_RE.test(normalizePhone(targetContact)) || normalizePhone(targetContact) === IA360_OWNER_NUMBER)) { + return deny('synthetic_approve_real_contact', `Gate QA: la aprobación vino de un payload sintético del harness y ${name} (${targetContact}) es un contacto real. No envié nada.`); + } + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. // '*' = la aprobación explícita del owner autoriza a cualquier contacto. const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); @@ -4408,7 +4671,8 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } label: openerLabel, body: pf.sequence_candidate.draft, }); - handlerFor = `${record.message_id}:direct:${targetContact}`; + // Paridad con el nuevo formato de sendIa360DirectText (incluye label). + handlerFor = `${record.message_id}:direct:${targetContact}:${openerLabel}`; } if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); const status = await waitForIa360OutboundStatus(handlerFor); @@ -4938,13 +5202,48 @@ async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerB return false; } +// G-BRAIN sandbox QA (incidente 06-11: el harness inundó el WhatsApp real de Alek +// con tarjetas/readouts de contactos QA). Una tarjeta/texto al OWNER cuyo origen es +// un inbound SINTÉTICO (wamid.e2e.* / qa-*) NO egresa al WhatsApp real: queda como +// evidencia verificable en coexistence.ia360_qa_evidence (los harness E2E leen esa +// tabla). Devuelve true si el egress quedó sandboxeado (el caller sigue su flujo +// normal creyendo que envió, que es exactamente la semántica del sandbox). +async function recordIa360QaEvidence({ record, toNumber, kind, label, messageBody, payload = null }) { + try { + await pool.query( + `INSERT INTO coexistence.ia360_qa_evidence + (origin_wamid, contact_number, to_number, kind, label, message_body, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + record?.message_id || null, + record?.contact_number || null, + String(toNumber || ''), + kind, + label || null, + messageBody || null, + payload ? JSON.stringify(payload) : null, + ] + ); + console.log('[ia360-qa-sandbox] egress al owner OMITIDO (origen sintético) label=%s origen=%s', label, record?.message_id); + return true; + } catch (err) { + console.error('[ia360-qa-sandbox] evidence insert error:', err.message); + return true; // sandbox fail-closed: aunque falle la evidencia, NO egresar al owner real + } +} + // Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la // fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound // (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro // numero, no colisiona). try/catch propio: nunca tumba el webhook. async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { try { - if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + // G-BRAIN sandbox: origen sintético (harness E2E) => la tarjeta se persiste en + // chat_history con status='qa_sandboxed' (los harness la correlacionan por + // message_id como contexto de taps) pero JAMÁS se encola hacia Meta: cero + // tarjetas de QA en el WhatsApp real de Alek. + const qaSandbox = isIa360SyntheticWamid(record?.message_id); + if (!qaSandbox && !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } const localId = await insertPendingRow({ @@ -4952,8 +5251,14 @@ async function sendOwnerInteractive({ record, interactive, label, messageBody, t toNumber: IA360_OWNER_NUMBER, messageType: 'interactive', messageBody: messageBody || 'IA360 owner', - templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify', ...(qaSandbox ? { qa_sandbox: true } : {}) }, + ...(qaSandbox ? { status: 'qa_sandboxed' } : {}), }); + if (!localId) return false; // dedupe atómico ON CONFLICT (p.ej. doble tarjeta del mismo wamid) + if (qaSandbox) { + console.log('[ia360-qa-sandbox] tarjeta al owner SIN egress real label=%s origen=%s', label, record?.message_id); + return true; + } await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); return true; } catch (err) { @@ -4966,7 +5271,10 @@ async function sendOwnerInteractive({ record, interactive, label, messageBody, t // desde la rama owner, donde record.contact_number es Alek, no el prospecto). async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { try { - if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + // G-BRAIN sandbox: texto al OWNER real con origen sintético => fila + // 'qa_sandboxed' (visible para los harness), sin egress a Meta. + const qaSandbox = normalizePhone(toNumber) === IA360_OWNER_NUMBER && isIa360SyntheticWamid(record?.message_id); + if (!qaSandbox && normalizePhone(toNumber) === IA360_OWNER_NUMBER && !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } @@ -4975,8 +5283,17 @@ async function sendIa360DirectText({ record, toNumber, body, label, targetContac toNumber, messageType: 'text', messageBody: body, - templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + // G-BRAIN: el label forma parte del handler — dos textos DISTINTOS al mismo + // destino para el mismo inbound (p.ej. readout + "listo, enviado") son + // legítimos y el índice único no debe colapsarlos. + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}:${label}`, source: 'webhook_owner_direct', ...(qaSandbox ? { qa_sandbox: true } : {}) }, + ...(qaSandbox ? { status: 'qa_sandboxed' } : {}), }); + if (!localId) return false; // dedupe atómico ON CONFLICT + if (qaSandbox) { + console.log('[ia360-qa-sandbox] texto al owner SIN egress real label=%s origen=%s', label, record?.message_id); + return true; + } await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); return true; } catch (err) { @@ -5561,6 +5878,22 @@ async function handleIa360BotFailure({ record, reason, alreadyResponded = false } } // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + // G-BRAIN: los contactos QA (rango exacto 52199900XXXXX) NUNCA alertan al + // WhatsApp real del owner — el failure queda registrado en ia360_bot_failures + + // evidencia QA, y se acabó. (Las 4 tarjetas "el bot no resolvió un mensaje de + // 5219990000950" del 06-11 eran ruido de harness en el teléfono de Alek.) + // OJO (hallazgo de review): patrón QA exacto, NO ^521999 — 999 es la lada de + // Mérida y un cliente real de ahí debe seguir alertando al owner. + if (failureId != null && IA360_QA_NUMBER_RE.test(String(record.contact_number || ''))) { + await recordIa360QaEvidence({ + record, + toNumber: IA360_OWNER_NUMBER, + kind: 'owner_bot_failure_suppressed', + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number} (${String(reason || 'no-manejado').slice(0, 80)})`, + }).catch(() => {}); + return failureId; + } if (failureId != null) { try { const reasonShort = String(reason || 'no-manejado').slice(0, 80); @@ -5754,17 +6087,51 @@ async function handleIa360FreeText(record) { // [qa-force-agent-down] simula agente caído SOLO para números QA (E2E). const qaForceDown = IA360_QA_NUMBER_RE.test(String(record.contact_number || '')) && /\[qa-force-agent-down\]/i.test(String(record.message_body || '')); + // G-BRAIN: estado conversacional + memoria precargada también para beta — + // mismo presupuesto de preguntas, cierre con opciones y no-repetir. + const betaConvState = loadIa360ConvState(contactContext); + let betaMemory = null; + try { + betaMemory = await lookupIa360MemoryContext({ record, contact: contactContext, limit: 8 }); + } catch (e) { + console.error('[ia360-agent] beta memory lookup failed:', e.message); + } const agentBeta = qaForceDown ? null : await callIa360Agent({ record, stageName: deal.stage_name, - roleHint: buildIa360ClienteActivoBetaRoleHint(contactContext), + roleHint: [ + buildIa360ClienteActivoBetaRoleHint(contactContext), + buildIa360ConversationRoleHint({ contact: contactContext, stageName: deal.stage_name, convState: betaConvState, memory: betaMemory, messageBody: record.message_body }), + ].join(' | '), + memory: betaMemory, }); - const betaReply = agentBeta && typeof agentBeta.reply === 'string' ? agentBeta.reply.trim() : ''; + let betaReply = agentBeta && typeof agentBeta.reply === 'string' ? agentBeta.reply.trim() : ''; + // Deuda G-LIVE cerrada: post-filtro de pitch en código. Si el contacto es + // do_not_pitch y el reply huele a venta, NO sale: respuesta segura + aviso. + if (betaReply && isIa360DoNotPitchContact(contactContext) && ia360ReplySmellsLikePitch(betaReply)) { + console.error('[ia360-agent] post-filtro pitch: reply bloqueado para do_not_pitch contact=%s', maskIa360Number(record.contact_number)); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_beta_pitch_blocked', + body: `IA360: bloqueé un reply con tono de venta para ${record.contact_name || record.contact_number} (do_not_pitch). Reply original: "${betaReply.slice(0, 200)}"`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-agent] pitch-block alert error:', e.message)); + betaReply = 'Tomo nota de esto y lo dejo registrado para Alek. Si te sirve, en el siguiente paso te confirmo cómo queda y seguimos por aquí.'; + } if (betaReply) { - const sentBeta = await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_agent_reply', body: betaReply }); + const sentBeta = await enqueueIa360AgentReply({ record, label: 'ia360_cliente_activo_beta_agent_reply', body: betaReply }); + if (sentBeta === 'superseded') { + // Otra ruta (botón/secuencia) ya contestó este intercambio: no alertar. + responded = true; + return; + } if (sentBeta) { + updateIa360ConvState({ record, contact: contactContext, agent: agentBeta, replyBody: betaReply, convState: betaConvState }) + .catch(e => console.error('[ia360-conv-state] beta update error:', e.message)); // Gestión de citas detectada por el agente: la acción de calendario del // embudo no aplica al pseudo-deal beta, así que el loop lo cierra Alek. const betaAction = String(agentBeta.action || agentBeta.intent || ''); @@ -5793,10 +6160,28 @@ async function handleIa360FreeText(record) { responded = true; return; } - const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + // G-BRAIN: la conversación del embudo también lleva estado (presupuesto de + // preguntas, no-repetir, cierre con opciones, modo por etapa) + memoria del + // contacto inyectada vía roleHint — el caso José Ramón: 8 preguntas seguidas + // sin propuesta con el deal YA en "Dolor calificado". + const convContact = await loadIa360ContactContext(record).catch(() => null); + const convState = loadIa360ConvState(convContact); + let convMemory = null; + try { + convMemory = await lookupIa360MemoryContext({ record, contact: convContact, limit: 8 }); + } catch (e) { + console.error('[ia360-agent] conv memory lookup failed:', e.message); + } + const agent = await callIa360Agent({ + record, + stageName: deal.stage_name, + roleHint: buildIa360ConversationRoleHint({ contact: convContact, stageName: deal.stage_name, convState, memory: convMemory, messageBody: record.message_body }), + memory: convMemory, + }); if (!agent || !agent.reply) { // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. - await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + // G-BRAIN: vía enqueueIa360AgentReply — si otra ruta ya contestó, ni holding. + await enqueueIa360AgentReply({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); responded = true; // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), @@ -6093,8 +6478,17 @@ async function handleIa360FreeText(record) { if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); } - const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); - if (sentReply) responded = true; + // G-BRAIN: candado anti-doble-ruta (incidente 1651/1653) + estado conversacional. + const sentReply = await enqueueIa360AgentReply({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply === 'superseded') { + responded = true; // otra ruta (botón seq_*) ya contestó este intercambio + return; + } + if (sentReply) { + responded = true; + updateIa360ConvState({ record, contact: convContact, agent, replyBody: agent.reply, convState }) + .catch(e => console.error('[ia360-conv-state] update error:', e.message)); + } } catch (err) { console.error('[ia360-agent] handleIa360FreeText error:', err.message); // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. diff --git a/backend/src/services/messageSender.js b/backend/src/services/messageSender.js index caadda9..1ae3914 100644 --- a/backend/src/services/messageSender.js +++ b/backend/src/services/messageSender.js @@ -44,15 +44,32 @@ async function resolveAccount({ accountId, fromPhoneNumber }) { /** * Insert an optimistic chat_history row that the UI shows as "sending…". * Returns the local message_id used so caller can correlate later updates. + * + * G-BRAIN dedupe atómico: el índice único parcial + * chat_history_ia360_handler_dedupe (contact_number, template_meta->>'ia360_handler_for') + * convierte el dedupe IA360 en una garantía de base de datos. Si otra ruta ya + * insertó una respuesta para el mismo handler, este INSERT no inserta nada + * (ON CONFLICT DO NOTHING) y se devuelve null — el caller NO debe encolar. + * Las filas sin ia360_handler_for (broadcasts, automation, chat manual) quedan + * fuera del índice parcial y conservan el comportamiento de siempre. + * + * `db` acepta un client de pg para participar en una transacción del caller + * (candado anti-doble-ruta del webhook IA360); default: el pool global. + * `status` permite filas sandbox ('qa_sandboxed': fila visible y correlacionable + * para los harness E2E, pero JAMÁS encolada hacia Meta); default: 'sending'. */ -async function insertPendingRow({ account, toNumber, messageType, messageBody, mediaUrl = null, mediaMime = null, templateMeta = null, contextMessageId = null, rawPayloadExtra = null }) { +async function insertPendingRow({ account, toNumber, messageType, messageBody, mediaUrl = null, mediaMime = null, templateMeta = null, contextMessageId = null, rawPayloadExtra = null, db = pool, status = 'sending' }) { const messageId = localMessageId(); - await pool.query( + const res = await db.query( `INSERT INTO coexistence.chat_history (message_id, phone_number_id, wa_number, contact_number, to_number, direction, message_type, message_body, raw_payload, media_url, media_mime_type, status, timestamp, template_meta, context_message_id) - VALUES ($1,$2,$3,$4,$5,'outgoing',$6,$7,$8,$9,$10,'sending',NOW(),$11,$12)`, + VALUES ($1,$2,$3,$4,$5,'outgoing',$6,$7,$8,$9,$10,$13,NOW(),$11,$12) + ON CONFLICT (contact_number, (template_meta->>'ia360_handler_for')) + WHERE direction = 'outgoing' AND (template_meta->>'ia360_handler_for') IS NOT NULL + DO NOTHING + RETURNING message_id`, [ messageId, account.phoneNumberId, @@ -66,8 +83,14 @@ async function insertPendingRow({ account, toNumber, messageType, messageBody, m mediaMime, templateMeta ? JSON.stringify(templateMeta) : null, contextMessageId || null, + status, ] ); + if (res.rowCount === 0) { + console.log('[messageSender] dedupe ON CONFLICT: fila no insertada (handler ya respondido) to=%s handler=%s', + String(toNumber).replace(/\D/g, ''), templateMeta?.ia360_handler_for || '-'); + return null; + } return messageId; } diff --git a/gbrain-e2e.sh b/gbrain-e2e.sh new file mode 100644 index 0000000..1dbd0c2 --- /dev/null +++ b/gbrain-e2e.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E G-BRAIN — el agente conversa con memoria, cierre y contexto (2026-06-11) +# SIM 1 — Contexto inyectado: contacto QA con facts precargados → el reply +# del agente refleja los facts (memory-lookup vía roleHint). +# SIM 2 — Corrección del contacto → persistida en ia360_conv_state.corrections +# y respetada en el turno siguiente. +# SIM 3 — Señal de compra ("no tenemos...") → el reply propone siguiente +# paso, el estado acumula turnos/preguntas. +# SIM 4 — Modo por etapa: deal movido a "Dolor calificado" → el reply propone +# (no excava) y el estado registra el presupuesto de preguntas. +# SIM 5 — DEDUPE anti-doble-ruta: dos inbound casi simultáneos → UNA sola +# respuesta del agente (la otra queda 'superseded' en logs). +# SIM 6 — Sandbox QA: todo el egress de este sim quedó sin salida real +# (wamid.qa-sandbox.* en la cola; 0 tarjetas e2e con egress al owner). +# Uso: bash gbrain-e2e.sh (correr en el VPS). Solo números QA. +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="873315362541590" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" +QA="5219990000951" # QA G-BRAIN conversacional (no-beta, deal en pipeline IA360) + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado='$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_nonempty(){ if [ -n "$2" ]; then ok "$1"; else bad "$1" "(vacío)" "no-vacío"; fi; } +chk_match(){ if echo "$2" | grep -qiE "$3"; then ok "$1"; else bad "$1" "$(echo "$2" | head -c 160)" "matchea:$3"; fi; } + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } + +text_payload(){ # $1=from $2=wamid $3=texto + printf '{"object":"whatsapp_business_account","entry":[{"id":"WABA","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"%s","phone_number_id":"%s"},"contacts":[{"wa_id":"%s","profile":{"name":"QA Brain Conv"}}],"messages":[{"from":"%s","id":"%s","timestamp":"%s","type":"text","text":{"body":"%s"}}]}}]}]}' \ + "$WA" "$PID_NUM" "$1" "$1" "$2" "$(ts)" "$3" +} + +wait_reply_after(){ # $1=min_id $2=timeout_s → message_body del primer ia360_ai_reply con id > min_id + local deadline=$(( $(date +%s) + ${2:-60} )); local body="" + while [ "$(date +%s)" -lt "$deadline" ]; do + body=$(psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND template_meta->>'label' IN ('ia360_ai_reply','ia360_ai_holding') AND id > $1 ORDER BY id ASC LIMIT 1") + [ -n "$body" ] && { printf '%s' "$body"; return 0; } + sleep 4 + done + printf '%s' "" +} + +max_id(){ psql_q "SELECT COALESCE(MAX(id),0) FROM coexistence.chat_history"; } + +send_and_wait(){ # $1=texto → imprime reply (y lo registra en el hilo) + local MID; MID=$(max_id) + local W="wamid.e2e.gbrain.$(ts).$RANDOM" + post_webhook "$(text_payload "$QA" "$W" "$1")" >/dev/null + wait_reply_after "$MID" 60 +} + +echo "=== STEP 0 — preparar contacto QA conversacional ($QA) con deal + facts ===" +psql_q "DELETE FROM coexistence.deals WHERE contact_number='$QA'" >/dev/null +psql_q "DELETE FROM coexistence.chat_history WHERE contact_number='$QA'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_memory_facts WHERE contact_number='$QA'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_memory_events WHERE contact_number='$QA'" >/dev/null +psql_q "DELETE FROM coexistence.contacts WHERE contact_number='$QA'" >/dev/null +psql_q "INSERT INTO coexistence.contacts (wa_number, contact_number, name, tags, custom_fields) + VALUES ('$WA', '$QA', 'QA Brain Conv', '[\"staged\"]'::jsonb, '{\"staged\": true}'::jsonb)" >/dev/null +PIPE_ID=$(psql_q "SELECT id FROM coexistence.pipelines WHERE name='IA360 WhatsApp Revenue Pipeline' LIMIT 1") +STAGE_ID=$(psql_q "SELECT id FROM coexistence.pipeline_stages WHERE pipeline_id=$PIPE_ID AND name='Intención detectada' LIMIT 1") +psql_q "INSERT INTO coexistence.deals (pipeline_id, stage_id, contact_wa_number, contact_number, title, status) + VALUES ($PIPE_ID, $STAGE_ID, '$WA', '$QA', 'IA360 · QA Brain Conv (G-BRAIN E2E)', 'open')" >/dev/null +psql_q "INSERT INTO coexistence.ia360_memory_facts (fact_key, contact_wa_number, contact_number, project_name, persona, role, recurring_pain, affected_process, confidence, status, last_seen_at) + VALUES ('gbrain-e2e-fact-taller-$QA', '$WA', '$QA', 'QA Brain Conv Empresa', 'operaciones', 'Gerente de operaciones', 'distribuidora de refacciones con taller: unidades detenidas días sin responsable claro', 'taller -> diagnóstico -> refacción -> entrega', 0.9, 'confirmado', NOW())" >/dev/null +chk "deal QA en Intención detectada" "$(psql_q "SELECT count(*) FROM coexistence.deals WHERE contact_number='$QA' AND status='open'")" "1" +chk "fact precargado" "$(psql_q "SELECT count(*) FROM coexistence.ia360_memory_facts WHERE contact_number='$QA'")" "1" + +echo "" +echo "=== SIM 1 — contexto inyectado: el reply refleja los facts precargados ===" +R1=$(send_and_wait "Hola, Alek me dijo que me podías ayudar a ordenar la operación. ¿Por dónde empezamos?") +chk_nonempty "SIM1 reply del agente" "$R1" +echo " [HILO] contacto: Hola, Alek me dijo que me podías ayudar a ordenar la operación. ¿Por dónde empezamos?" +echo " [HILO] agente: $R1" +chk_match "SIM1 reply usa los facts (taller/unidades/refacciones)" "$R1" "taller|unidad|refacci|detenid" + +echo "" +echo "=== SIM 2 — corrección del contacto: registrada y respetada ===" +R2=$(send_and_wait "No, en realidad el taller ya lo resolvimos. El problema sí es la cobranza: nadie da seguimiento a los pagos.") +chk_nonempty "SIM2 reply tras corrección" "$R2" +echo " [HILO] contacto: No, en realidad el taller ya lo resolvimos. El problema sí es la cobranza: nadie da seguimiento a los pagos." +echo " [HILO] agente: $R2" +CORR=$(psql_q "SELECT custom_fields->'ia360_conv_state'->'corrections'->>0 FROM coexistence.contacts WHERE contact_number='$QA'") +chk_nonempty "SIM2 corrección persistida en ia360_conv_state" "$CORR" +chk_match "SIM2 reply gira a cobranza" "$R2" "cobranza|pago|cartera|seguimiento" + +echo "" +echo "=== SIM 3 — señal de compra: propone siguiente paso, no otra pregunta ===" +R3=$(send_and_wait "Es que no tenemos estrategias de seguimiento de cobranza, ¿cómo le hacemos?") +chk_nonempty "SIM3 reply ante señal de compra" "$R3" +echo " [HILO] contacto: Es que no tenemos estrategias de seguimiento de cobranza, ¿cómo le hacemos?" +echo " [HILO] agente: $R3" +chk_match "SIM3 reply propone (paso/llamada/plan/mapa)" "$R3" "propon|llamada|plan|mapa|paso|empez|Alek" +TURNS=$(psql_q "SELECT custom_fields->'ia360_conv_state'->>'turns' FROM coexistence.contacts WHERE contact_number='$QA'") +chk_nonempty "SIM3 estado acumula turnos (turns=$TURNS)" "$TURNS" + +echo "" +echo "=== SIM 4 — modo por etapa: deal en Dolor calificado → PROPONER ===" +STAGE_DC=$(psql_q "SELECT id FROM coexistence.pipeline_stages WHERE pipeline_id=$PIPE_ID AND name='Dolor calificado' LIMIT 1") +psql_q "UPDATE coexistence.deals SET stage_id=$STAGE_DC WHERE contact_number='$QA'" >/dev/null +R4=$(send_and_wait "Sí, eso nos pega cada mes con la cartera vencida.") +chk_nonempty "SIM4 reply en modo proponer" "$R4" +echo " [HILO] contacto: Sí, eso nos pega cada mes con la cartera vencida." +echo " [HILO] agente: $R4" +chk_match "SIM4 reply propone acciones (no excava)" "$R4" "propon|llamada|plan|mapa|resum|opci|paso|empez|asignar|separar|alerta|armar[ií]a|cosas:" +ST_JSON=$(psql_q "SELECT custom_fields->'ia360_conv_state' FROM coexistence.contacts WHERE contact_number='$QA'") +echo " [ESTADO] ia360_conv_state: $(echo "$ST_JSON" | head -c 400)" +chk_match "SIM4 estado guarda preguntas hechas" "$ST_JSON" "questions_asked" + +echo "" +echo "=== SIM 5 — DEDUPE: dos inbound casi simultáneos → UNA respuesta ===" +MID5=$(max_id) +W5A="wamid.e2e.gbrain.dup.$(ts).a$RANDOM" +W5B="wamid.e2e.gbrain.dup.$(ts).b$RANDOM" +post_webhook "$(text_payload "$QA" "$W5A" "Una duda: ¿esto se integra con lo que ya usamos?")" >/dev/null & +sleep 1 +post_webhook "$(text_payload "$QA" "$W5B" "¿Y cuánto tardaría en quedar andando?")" >/dev/null +wait +echo " esperando a que el agente procese ambos (45 s)..." +sleep 45 +N5=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND template_meta->>'label' IN ('ia360_ai_reply','ia360_ai_holding') AND id > $MID5") +chk "SIM5 exactamente UNA respuesta del agente" "$N5" "1" +SUPLOG=$(docker logs "$BE" --since 3m 2>&1 | grep -c 'ia360-dedupe. reply del agente DESCARTADO' || true) +echo " [INFO] descartes anti-doble-ruta en logs (3 min): $SUPLOG" + +echo "" +echo "=== SIM 6 — sandbox QA: cero egress real en todo el sim ===" +QASENT=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$QA' AND status='sent' AND message_id NOT LIKE 'wamid.qa-sandbox.%'") +chk "SIM6 todo egress al QA quedó en sandbox (0 wamid reales)" "$QASENT" "0" +OWNER_E2E=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'ia360_handler_for' LIKE 'wamid.e2e.gbrain%' AND status NOT IN ('qa_sandboxed')") +chk "SIM6 cero tarjetas e2e con egress real al owner" "$OWNER_E2E" "0" + +echo "" +echo "=== HILO COMPLETO DEL SIM (chat_history) ===" +psql_q "SELECT id || ' | ' || direction || ' | ' || COALESCE(status,'-') || ' | ' || LEFT(regexp_replace(message_body, E'[\\n\\r]+', ' ', 'g'), 150) FROM coexistence.chat_history WHERE contact_number='$QA' ORDER BY id" + +echo "" +echo "=== RESULTADO G-BRAIN: PASS=$PASS FAIL=$FAIL ===" +[ "$FAIL" = "0" ] && exit 0 || exit 1 diff --git a/gcold-e2e.sh b/gcold-e2e.sh index d0077d5..22500a4 100644 --- a/gcold-e2e.sh +++ b/gcold-e2e.sh @@ -33,6 +33,7 @@ chk_not_has(){ if echo "$2" | grep -qF "$3"; then bad "$1" "$2" "NO contiene:$3" chk_any(){ # $1=nombre $2=valor $3=patron grep -E (alternativas) if echo "$2" | grep -qE "$3"; then ok "$1"; else bad "$1" "$2" "matchea:$3"; fi } +chk_nonempty(){ if [ -n "$2" ]; then ok "$1"; else bad "$1" "(vacío)" "no-vacío"; fi; } psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } @@ -47,11 +48,13 @@ post_webhook(){ ts(){ date +%s; } WAMID_BASE="wamid.e2e.gcold.$(ts)" +# G-BRAIN: filtro temporal (15 min) — sin él, un label de una corrida anterior +# produce falsos PASS (p.ej. un owner_approve_send_blocked viejo). owner_msg_id_by_label(){ # $1=label → message_id de la última saliente al owner con ese label - psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" + psql_q "SELECT message_id FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' AND created_at > NOW() - INTERVAL '15 minutes' ORDER BY id DESC LIMIT 1" } owner_body_by_label(){ - psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' AND created_at > NOW() - INTERVAL '15 minutes' ORDER BY id DESC LIMIT 1" } # G-COLD: espera con polling a que aparezca la saliente al owner con ese label. @@ -170,6 +173,18 @@ echo "" echo "=== SIM D — aliado (5219990077704): template EN REVISIÓN bloquea de punta a punta ===" NUMD="5219990077704" +# G-BRAIN fixture: ia360_aliado_criterios_fit ya está APPROVED en Meta. Para +# conservar la cobertura del caso "template en revisión", el SIM D lo simula +# mutando el cache local de status y lo RESTAURA SIEMPRE al salir (trap EXIT). +# OJO: el check constraint solo admite DRAFT/SUBMITTED/APPROVED/REJECTED/PAUSED/ +# DISABLED — se usa SUBMITTED, que ia360ColdAvailability trata como "en revisión". +restore_criterios_fit(){ + psql_q "UPDATE coexistence.message_templates SET status='APPROVED' WHERE name='ia360_aliado_criterios_fit'" >/dev/null +} +trap restore_criterios_fit EXIT +psql_q "UPDATE coexistence.message_templates SET status='SUBMITTED' WHERE name='ia360_aliado_criterios_fit'" >/dev/null +chk "fixture: criterios_fit simulado en revisión" "$(psql_q "SELECT status FROM coexistence.message_templates WHERE name='ia360_aliado_criterios_fit'")" "SUBMITTED" + echo "--- STEP 0 — limpieza estado QA $NUMD ---" clean_qa_number "$NUMD" ok "estado limpio ($NUMD)" @@ -213,6 +228,10 @@ chk_has "cinturón avisa template no aprobado" "$BLOCKED" "aún no está aprobad TCOUNT=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$NUMD' AND message_type='template'") chk "cero templates salientes al QA" "$TCOUNT" "0" +# G-BRAIN: restaurar el status real (APPROVED) ya, sin esperar al EXIT. +restore_criterios_fit +chk "fixture: criterios_fit restaurado a APPROVED" "$(psql_q "SELECT status FROM coexistence.message_templates WHERE name='ia360_aliado_criterios_fit'")" "APPROVED" + # ============================================================================ sleep 61 # G-COLD: ventana nueva del presupuesto owner (6 msg/60s) echo "" @@ -247,7 +266,15 @@ JR_SEL=$(wait_owner_msg "owner_sequence_selector_${JR}_persona_aliado" 90) echo "--- STEP 3 — la tarjeta simulada (ranking persistido completo) ---" psql_q "SELECT jsonb_pretty(custom_fields->'ia360_selector_ranking') FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$JR'" JR_DESC=$(psql_q "SELECT r->>'description' FROM coexistence.contacts c, jsonb_array_elements(c.custom_fields->'ia360_selector_ranking'->'rows') r WHERE c.wa_number='$WA' AND c.contact_number='$JR' AND r->>'title'='Criterios de fit' LIMIT 1") -chk_any "row Criterios de fit con marca de disponibilidad" "$JR_DESC" "template|✓" +# G-BRAIN: la marca de disponibilidad SOLO existe en modo frío (fuera de ventana +# de 24 h). Si JR tiene conversación viva (escribió hace <24 h), el selector va +# en modo caliente y el row sin marca es el comportamiento CORRECTO. +JR_OUTW=$(psql_q "SELECT custom_fields->'ia360_selector_ranking'->'cold'->>'outside_window' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$JR'") +if [ "$JR_OUTW" = "true" ]; then + chk_any "row Criterios de fit con marca de disponibilidad (modo frío)" "$JR_DESC" "template|✓" +else + chk_nonempty "row Criterios de fit presente (modo caliente, sin marca: ventana abierta)" "$JR_DESC" +fi echo "--- STEP 4 — CERO egress a JR durante la simulación ---" JR_EGRESS=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$JR' AND created_at > now() - interval '10 minutes'") diff --git a/glive-e2e.sh b/glive-e2e.sh index c71667b..6f61746 100755 --- a/glive-e2e.sh +++ b/glive-e2e.sh @@ -100,8 +100,12 @@ chk_nonempty "SIM2 holding al contacto" "$H2" F2=$(psql_q "SELECT id||' | '||status||' | '||reason FROM coexistence.ia360_bot_failures WHERE contact_number='$QA' AND reason LIKE 'cliente activo/beta: agente IA%' ORDER BY id DESC LIMIT 1") chk_nonempty "SIM2 failure registrado" "$F2" echo " failure: $F2" -A2=$(psql_q "SELECT LEFT(message_body,100) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='owner_bot_failure' AND message_body LIKE '%$QA%' ORDER BY id DESC LIMIT 1") -chk_nonempty "SIM2 alerta al owner" "$A2" +# G-BRAIN sandbox QA: la alerta de un contacto 521999* ya NO egresa al WhatsApp +# real del owner — queda como evidencia verificable en ia360_qa_evidence. +A2=$(psql_q "SELECT LEFT(message_body,100) FROM coexistence.ia360_qa_evidence WHERE kind='owner_bot_failure_suppressed' AND contact_number='$QA' ORDER BY id DESC LIMIT 1") +chk_nonempty "SIM2 alerta al owner suprimida con evidencia (sandbox QA)" "$A2" +A2REAL=$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='owner_bot_failure' AND message_body LIKE '%$QA%' AND created_at > NOW() - INTERVAL '10 minutes'") +chk "SIM2 cero tarjetas QA al owner real" "$A2REAL" "0" echo "=== SIM 3 — guard del inyector: sintético a número real → bloqueado ===" W3="wamid.e2e.glive.guard.$(ts).$RANDOM" From 2af49a4908177f2c7c25c6c32efec9752faae0b1 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Fri, 12 Jun 2026 01:35:53 +0000 Subject: [PATCH 30/39] =?UTF-8?q?feat(ia360):=20G-RAG=20=E2=80=94=20puente?= =?UTF-8?q?=20seguro=20AlekContenido=20<->=20RAG=20(tabla=20puente,=20jaul?= =?UTF-8?q?as=20por=20proyecto,=20round-trip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identidad determinista por teléfono entre el vault de Obsidian y el RAG: - Tabla puente coexistence.ia360_vault_links: N notas por contacto, una nota vinculada apunta a UN solo contacto (índice parcial único) y los rechazos sellados no se re-preguntan. Auto-match SOLO por frontmatter telefono_wa (blocklist bot/owner/521999%); el nombre JAMÁS auto-matchea (homónimos). - Indexador y drenador en scripts/vault-bridge.js corriendo en el HOST (cron */10 con flock). Decisión de arquitectura: host-script en vez de bind mount read-only porque el round-trip NECESITA escribir el vault (un mount ro no alcanza y uno rw ampliaría la superficie del contenedor expuesto a Meta); el contenedor backend nunca toca el filesystem del vault — la DB es la única interfaz (ia360_vault_notes), lo que además deja todo testeable por SQL. - Enriquecimiento determinista a ia360_memory_facts con source=alekcontenido, fact_key=alekcontenido:# (provenance reversible vía payload.note_path), al alta de contacto y con el comando del owner "sincroniza a "; marca rag_enriched_at en el contacto. - Tarjeta de candidatos por nombre (fuzzy Levenshtein<=1, tipo Antúnez/Antunes) con taps owner_vlink/owner_vnone que sellan vínculo o rechazo; sin tap no hay sync. - JAULA por proyecto en lookupIa360MemoryContext (decisión B1 de Alek 06-11): con proyecto activo declarado (beta.project || custom_fields.project_name; account_name NO es proxy de proyecto) el agente recibe SOLO facts de ese proyecto + generales de persona; sin proyecto activo se conserva el comportamiento G-BRAIN para no esconder memoria legítima. - Round-trip: el drenador de ia360_docs_sync escribe sobre la nota VINCULADA (por vínculo, jamás por nombre) o crea nota nueva en Areas/CRM/contactos/ desde el template; idempotente por marcador de id; drenadas las 2 filas reales. Contención dura de path dentro del vault. - Test no-silencio actualizado: G-BRAIN compuso el roleHint como arreglo y el patrón estático viejo ya no existía (fallaba 3/4 ANTES de este cambio). Piloto real: JR vinculado por teléfono (4 facts Konforthome) y Andrés con sus 2 notas selladas por tap (5 facts Camiones-Selectos + 2 ArrendadoraMETA, cero fuga inter-proyecto). E2E grag-e2e.sh 43/43; regresiones glive-e2e 14/14, gbrain-e2e 16/16, no-silencio 4/4; revisión adversarial APROBADO CON CAMBIOS con los P1 aplicados (dq tag parcial, contención de path, llave de jaula). Co-Authored-By: Claude Fable 5 --- backend/src/routes/webhook.js | 628 +++++++++++++++++- backend/test/ia360NoSilenceRegression.test.js | 4 +- grag-e2e.sh | 290 ++++++++ scripts/vault-bridge.js | 442 ++++++++++++ 4 files changed, 1357 insertions(+), 7 deletions(-) create mode 100755 grag-e2e.sh create mode 100755 scripts/vault-bridge.js diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index d0b3947..628bb3d 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -811,18 +811,39 @@ async function persistIa360MemorySignals({ record, contact, signals }) { async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { await ensureIa360MemoryTables(); - const profile = getIa360ContactProfile(contact); + // G-RAG (revisión 06-11): la llave de la jaula es SOLO el proyecto declarado + // (beta.project o custom_fields.project_name). account_name NO es proxy de + // proyecto: usarlo escondía memoria real de contactos con empresa capturada + // y sin proyecto activo. + const cfJaula = contact?.custom_fields || {}; + const jaulaProjectKey = (cfJaula.ia360_cliente_activo_beta || {}).project || cfJaula.project_name || null; const params = [ record?.wa_number || null, record?.contact_number || null, - profile.projectName || null, + jaulaProjectKey, limit, ]; + // G-RAG JAULA por proyecto (decisión B1 de Alek 06-11): prohibida la fuga + // inter-proyecto. Con proyecto activo declarado, el agente recibe SOLO + // memoria de ESE proyecto + memoria general de persona (project_name + // vacío); lo que el mismo contacto tenga en OTRO proyecto queda FUERA. + // Sin proyecto activo no hay jaula que aplicar: se conserva el + // comportamiento G-BRAIN (toda la memoria del contacto). La comparación + // normaliza guiones/guiones bajos/espacios ('Camiones-Selectos' = + // 'Camiones Selectos'). El expediente del owner NO pasa por aquí: ese es + // cross-proyecto a propósito. + const jaulaWhere = `( + (contact_wa_number=$1 AND contact_number=$2 + AND (COALESCE($3::text,'')='' + OR COALESCE(project_name,'')='' + OR regexp_replace(lower(project_name),'[-_ ]','','g') = regexp_replace(lower($3),'[-_ ]','','g'))) + OR ($3::text IS NOT NULL AND $3<>'' AND + regexp_replace(lower(project_name),'[-_ ]','','g') = regexp_replace(lower($3),'[-_ ]','','g')) + )`; const events = await pool.query( `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at FROM coexistence.ia360_memory_events - WHERE ((contact_wa_number=$1 AND contact_number=$2) - OR ($3::text IS NOT NULL AND project_name=$3)) + WHERE ${jaulaWhere} ORDER BY created_at DESC LIMIT $4`, params @@ -830,12 +851,13 @@ async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { const facts = await pool.query( `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at FROM coexistence.ia360_memory_facts - WHERE ((contact_wa_number=$1 AND contact_number=$2) - OR ($3::text IS NOT NULL AND project_name=$3)) + WHERE ${jaulaWhere} ORDER BY last_seen_at DESC LIMIT $4`, params ); + console.log('[ia360-jaula] contacto=%s proyecto=%s facts=%d eventos=%d', + record?.contact_number || '-', jaulaProjectKey || '-', facts.rows.length, events.rows.length); return { events: events.rows, facts: facts.rows }; } @@ -5449,6 +5471,569 @@ async function handleIa360OwnerMemoryQuery({ record, query }) { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); } +// ─── G-RAG: puente seguro AlekContenido ↔ RAG IA360 (2026-06-11) ──────────── +// El vault de Obsidian se indexa desde el HOST (scripts/vault-bridge.js: el +// contenedor backend NO monta el vault); el backend solo lee ia360_vault_notes +// y administra los vínculos contacto↔nota en ia360_vault_links. Reglas duras: +// auto-match SOLO por teléfono (el nombre JAMÁS auto-matchea: homónimos), el +// owner confirma por tarjeta los candidatos por nombre, y los facts destilados +// caen en ia360_memory_facts con source='alekcontenido' (provenance reversible +// vía fact_key y payload.note_path). + +let ia360VaultTablesReady = null; + +async function ensureIa360VaultTables() { + if (!ia360VaultTablesReady) { + ia360VaultTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_vault_notes ( + note_id BIGSERIAL UNIQUE, + note_path TEXT PRIMARY KEY, + nombre TEXT, + nombre_normalizado TEXT, + telefono_wa TEXT, + project_name TEXT, + rol TEXT, + empresa TEXT, + frontmatter JSONB NOT NULL DEFAULT '{}'::jsonb, + contenido TEXT, + file_mtime TIMESTAMPTZ, + indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + missing_since TIMESTAMPTZ + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_vault_notes_tel_idx + ON coexistence.ia360_vault_notes (telefono_wa) WHERE telefono_wa IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_vault_notes_nombre_idx + ON coexistence.ia360_vault_notes (nombre_normalizado) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_vault_links ( + id BIGSERIAL PRIMARY KEY, + forgechat_contact_id BIGINT NOT NULL, + contact_number TEXT NOT NULL, + note_path TEXT NOT NULL, + project_name TEXT, + estado TEXT NOT NULL CHECK (estado IN ('vinculado','rechazado')), + matched_by TEXT NOT NULL CHECK (matched_by IN ('telefono','owner_tap','owner_reject')), + confirmado_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (forgechat_contact_id, note_path) + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_vault_links_note_vinculado_uidx + ON coexistence.ia360_vault_links (note_path) WHERE estado = 'vinculado' + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_vault_links_contact_idx + ON coexistence.ia360_vault_links (contact_number, estado) + `); + })().catch(err => { + ia360VaultTablesReady = null; + throw err; + }); + } + return ia360VaultTablesReady; +} + +// Blocklist del puente: bot, owner y TODO número QA/sintético 521999*. +function ia360VaultBlocklisted(num) { + const n = String(num || ''); + return n === IA360_BOT_WA_NUMBER || n === IA360_OWNER_NUMBER || /^521999/.test(n); +} + +// Levenshtein chico para el fuzzy de candidatos (Antúnez/Antunes ⇒ distancia 1 +// tras normalizar). Tokens cortos, sin librería. +function ia360Levenshtein(a, b) { + if (a === b) return 0; + const m = a.length; + const n = b.length; + if (!m) return n; + if (!n) return m; + let prev = Array.from({ length: n + 1 }, (_, j) => j); + for (let i = 1; i <= m; i++) { + const cur = [i]; + for (let j = 1; j <= n; j++) { + cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + } + prev = cur; + } + return prev[n]; +} + +// Carga la ficha de contacto por número; si el mismo número vive bajo varias +// cuentas WA, gana la del bot (la conversación IA360 real). +async function loadIa360VaultContactByNumber(contactNumber) { + const num = normalizePhone(contactNumber); + if (!num) return null; + const { rows } = await pool.query( + `SELECT id, wa_number, contact_number, name, profile_name, custom_fields + FROM coexistence.contacts + WHERE contact_number = $1 + ORDER BY (wa_number = $2) DESC + LIMIT 1`, + [num, IA360_BOT_WA_NUMBER] + ); + return rows[0] || null; +} + +// Auto-match SOLO por teléfono. El COUNT(DISTINCT)=1 garantiza que un teléfono +// jamás apunte a 2 contactos; el NOT EXISTS respeta vínculos de otros contactos +// y rechazos sellados de este contacto. El nombre JAMÁS auto-matchea. +async function ensureIa360VaultAutoLinks({ contact }) { + if (!contact || ia360VaultBlocklisted(contact.contact_number)) return []; + await ensureIa360VaultTables(); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_vault_links + (forgechat_contact_id, contact_number, note_path, project_name, estado, matched_by, confirmado_at) + SELECT $1, $2, n.note_path, n.project_name, 'vinculado', 'telefono', NOW() + FROM coexistence.ia360_vault_notes n + WHERE n.telefono_wa = $2 AND n.missing_since IS NULL + AND (SELECT COUNT(DISTINCT c2.id) FROM coexistence.contacts c2 WHERE c2.contact_number = $2) = 1 + AND NOT EXISTS (SELECT 1 FROM coexistence.ia360_vault_links l + WHERE l.note_path = n.note_path + AND (l.estado = 'vinculado' OR l.forgechat_contact_id = $1)) + ON CONFLICT (forgechat_contact_id, note_path) DO NOTHING + RETURNING note_path`, + [contact.id, contact.contact_number] + ); + return rows.map(r => r.note_path); +} + +// Limpieza de valores del vault: [[wikilinks]] → texto interno, sin markdown +// de negritas, espacios colapsados, tope 300 chars. +function ia360CleanVaultValue(v) { + return String(v == null ? '' : v) + .replace(/\[\[([^\]]+)\]\]/g, '$1') + .replace(/\*\*/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 300); +} + +// Destilación DETERMINISTA de una nota del vault (sin LLM): frontmatter a +// slots de ia360_memory_facts + hasta 3 dolores de la tabla del cuerpo. +function distillIa360VaultNote(note) { + const fm = note?.frontmatter || {}; + const facts = []; + const push = (slot, valor, sufijo) => { + const v = ia360CleanVaultValue(valor); + if (v) facts.push({ slot, valor: v, sufijo }); + }; + const pick = (...keys) => { + for (const k of keys) { + if (fm[k] != null && String(fm[k]).trim() !== '') return fm[k]; + } + return null; + }; + push('role', pick('rol', 'cargo', 'rol_principal', 'rol_en_meta'), 'rol'); + push('account_name', pick('empresa', 'organizacion', 'organización'), 'empresa'); + push('persona', pick('tipo', 'tipo_referencia', 'nivel_decision'), 'tipo'); + const canal = pick('canal_preferido'); + if (canal) push('preference', `Canal preferido: ${ia360CleanVaultValue(canal)}`, 'canal'); + // Filas de la tabla de dolores del cuerpo: "| 1 | **dolor** | ...". + const re = /^\|\s*\d+\s*\|\s*\*\*(.+?)\*\*/gm; + let m; + let i = 0; + while ((m = re.exec(String(note?.contenido || ''))) && i < 3) { + i += 1; + push('recurring_pain', m[1], `pain${i}`); + } + return facts; +} + +const IA360_VAULT_SLOT_COLUMNS = { + role: 'role', + account_name: 'account_name', + persona: 'persona', + preference: 'preference', + recurring_pain: 'recurring_pain', +}; + +// Vuelca a ia360_memory_facts lo destilado de las notas VINCULADAS del +// contacto. fact_key = 'alekcontenido:#' (dedupe natural); +// project_name = el de la NOTA (jaula correcta; notas de Areas/CRM traen NULL +// = fact general de persona). Devuelve { notas, facts, proyectos }. +async function enrichIa360ContactFromVault({ record, contact }) { + await ensureIa360VaultTables(); + const { rows: notes } = await pool.query( + `SELECT n.note_path, n.project_name, n.frontmatter, n.contenido + FROM coexistence.ia360_vault_links l + JOIN coexistence.ia360_vault_notes n ON n.note_path = l.note_path + WHERE l.forgechat_contact_id = $1 AND l.estado = 'vinculado' + AND n.missing_since IS NULL + ORDER BY l.confirmado_at DESC NULLS LAST`, + [contact.id] + ); + const result = { notas: [], facts: 0, proyectos: [] }; + if (!notes.length) return result; + for (const note of notes) { + result.notas.push({ note_path: note.note_path, project_name: note.project_name || null }); + if (note.project_name && !result.proyectos.includes(note.project_name)) result.proyectos.push(note.project_name); + for (const f of distillIa360VaultNote(note)) { + const col = IA360_VAULT_SLOT_COLUMNS[f.slot]; + if (!col) continue; + const factKey = `alekcontenido:${note.note_path}#${f.sufijo}`; + const payload = { note_path: note.note_path, origen: 'vault', valor: f.valor }; + await pool.query( + `INSERT INTO coexistence.ia360_memory_facts + (fact_key, source, contact_wa_number, contact_number, forgechat_contact_id, + project_name, ${col}, confidence, owner_review_status, status, payload) + VALUES ($1,'alekcontenido',$2,$3,$4,$5,$6,0.950,'owner_curated','confirmed',$7::jsonb) + ON CONFLICT (fact_key) DO UPDATE SET + ${col} = EXCLUDED.${col}, + project_name = EXCLUDED.project_name, + payload = EXCLUDED.payload, + evidence_count = coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at = NOW(), updated_at = NOW()`, + [ + factKey, + contact.wa_number, + contact.contact_number, + contact.id, + note.project_name || null, + f.valor, + JSON.stringify(payload), + ] + ); + result.facts += 1; + } + } + await mergeContactIa360State({ + waNumber: contact.wa_number, + contactNumber: contact.contact_number, + customFields: { rag_enriched_at: new Date().toISOString() }, + }); + console.log('[ia360-vault] enriquecido contacto=%s notas=%d facts=%d origen=%s', + contact.contact_number, result.notas.length, result.facts, record?.message_id || '-'); + return result; +} + +// Candidatos por NOMBRE: notas vivas no vinculadas a NADIE y sin fila sellada +// para ESTE contacto. Cada token (≥3 chars) del nombre del contacto debe +// aparecer en el nombre de la nota o estar a Levenshtein ≤ 1 de algún token. +async function buildIa360VaultCandidates(contact) { + await ensureIa360VaultTables(); + const display = contact?.name || contact?.profile_name || ''; + const tokens = ia360NormalizeNameForMatch(display).split(' ').filter(t => t.length >= 3); + if (!tokens.length) return []; + const { rows } = await pool.query( + `SELECT n.note_id, n.note_path, n.nombre, n.nombre_normalizado, n.project_name, n.rol, n.empresa + FROM coexistence.ia360_vault_notes n + WHERE n.missing_since IS NULL + AND NOT EXISTS (SELECT 1 FROM coexistence.ia360_vault_links l + WHERE l.note_path = n.note_path AND l.estado = 'vinculado') + AND NOT EXISTS (SELECT 1 FROM coexistence.ia360_vault_links l2 + WHERE l2.note_path = n.note_path AND l2.forgechat_contact_id = $1)`, + [contact.id] + ); + const scored = []; + for (const n of rows) { + const noteName = String(n.nombre_normalizado || ''); + if (!noteName) continue; + const noteTokens = noteName.split(' ').filter(Boolean); + let score = 0; + let all = true; + for (const t of tokens) { + if (noteName.includes(t) || noteTokens.some(nt => ia360Levenshtein(t, nt) <= 1)) score += 1; + else all = false; + } + if (all && score > 0) scored.push({ ...n, score }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 3); +} + +// Tarjeta de candidatos al owner (mismo patrón list del approve-send). Sin tap +// del owner = cero sync: el nombre solo PROPONE, nunca vincula. +async function sendIa360VaultCandidateCard({ record, contact, candidates = null }) { + const list = candidates || await buildIa360VaultCandidates(contact); + const display = contact.name || contact.profile_name || contact.contact_number; + if (!list.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_no_candidates', + body: `Sin notas candidatas en el vault para ${display}. Si existe la nota, captúrale telefono_wa y vuelve a pedir "sincroniza a ...".`, + targetContact: contact.contact_number, + ownerBudget: true, + }); + return false; + } + await mergeContactIa360State({ + waNumber: contact.wa_number, + contactNumber: contact.contact_number, + customFields: { ia360_vault_offered: list.map(c => c.note_id).join(',') }, + }); + const rows = list.map(c => ({ + id: `owner_vlink:${contact.contact_number}:${c.note_id}`, + title: String(c.nombre || c.note_path).slice(0, 24), + description: `${c.project_name || 'CRM general'} · ${c.rol || c.empresa || 'nota'}`.slice(0, 72), + })); + rows.push({ + id: `owner_vnone:${contact.contact_number}`, + title: 'Ninguno, crear nuevo', + description: 'No vincular; nota nueva en Areas/CRM/contactos/ cuando haya docs', + }); + return sendOwnerInteractive({ + record, + label: 'ia360_vault_candidates', + messageBody: `Candidatos vault para ${contact.contact_number}: ${list.map(c => `${c.nombre} (${c.project_name || 'CRM general'})`).join(', ')}`, + targetContact: contact.contact_number, + ownerBudget: true, + interactive: { + type: 'list', + body: { text: `Sin match por teléfono para ${display} (${contact.contact_number}). ¿Vinculo alguna nota del vault?` }, + action: { + button: 'Elegir nota', + sections: [{ + title: 'Notas del vault', + rows, + }], + }, + }, + }); +} + +// Tap owner_vlink: vínculo sellado por decisión EXPLÍCITA del owner. Un tap SÍ +// puede revertir un rechazo previo (decisión nueva, no re-pregunta). +async function handleIa360OwnerVaultLink({ record, targetContact, noteId }) { + try { + await ensureIa360VaultTables(); + const contact = await loadIa360VaultContactByNumber(targetContact); + const { rows: noteRows } = await pool.query( + `SELECT note_id, note_path, project_name, nombre + FROM coexistence.ia360_vault_notes + WHERE note_id = $1 + LIMIT 1`, + [noteId] + ); + const note = noteRows[0]; + if (!contact || !note) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_link_conflict', + body: `No pude vincular: ${!contact ? `no hay ficha de contacto para ${targetContact}` : `la nota ${noteId} ya no existe en el índice del vault`}. Corre el indexador y vuelve a pedir "sincroniza a ...".`, + targetContact, + ownerBudget: true, + }); + return; + } + try { + await pool.query( + `INSERT INTO coexistence.ia360_vault_links + (forgechat_contact_id, contact_number, note_path, project_name, estado, matched_by, confirmado_at) + VALUES ($1,$2,$3,$4,'vinculado','owner_tap',NOW()) + ON CONFLICT (forgechat_contact_id, note_path) DO UPDATE SET + estado='vinculado', matched_by='owner_tap', confirmado_at=NOW(), updated_at=NOW()`, + [contact.id, contact.contact_number, note.note_path, note.project_name || null] + ); + } catch (linkErr) { + // Solo la violación del índice parcial único (23505) significa "la nota + // ya pertenece a OTRO contacto"; lo demás es error interno y lo maneja + // el catch externo sin diagnósticos falsos. + if (linkErr.code !== '23505') throw linkErr; + console.warn('[ia360-vault] vínculo en conflicto note=%s contacto=%s: %s', note.note_path, targetContact, linkErr.message); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_link_conflict', + body: `No vinculé ${note.note_path}: esa nota ya está vinculada a OTRO contacto (una nota apunta a una sola persona). Si el vínculo actual está mal, dime y lo revisamos.`, + targetContact, + ownerBudget: true, + }); + return; + } + const enriched = await enrichIa360ContactFromVault({ record, contact }); + const display = contact.name || contact.profile_name || targetContact; + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_linked', + body: `Vinculé ${note.note_path} a ${display} (${targetContact}). Facts: ${enriched.facts} (proyecto: ${note.project_name || 'general'}). Si quieres vincular otra nota: "sincroniza a ${display}".`, + targetContact, + ownerBudget: true, + }); + } catch (err) { + console.error('[ia360-vault] owner_vlink error:', err.message); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_link_conflict', + body: `No pude completar el vínculo para ${targetContact} (error interno). Inténtalo de nuevo en un momento.`, + targetContact, + ownerBudget: true, + }).catch(() => {}); + } +} + +// Tap owner_vnone: sella como rechazados TODOS los candidatos ofrecidos en la +// tarjeta (no se vuelve a preguntar). Jamás degrada un vínculo ya sellado. +async function handleIa360OwnerVaultNone({ record, targetContact }) { + try { + await ensureIa360VaultTables(); + const contact = await loadIa360VaultContactByNumber(targetContact); + if (!contact) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_none_ack', + body: `No hay ficha de contacto para ${targetContact}; no había nada que sellar.`, + targetContact, + ownerBudget: true, + }); + return; + } + const offered = String(contact.custom_fields?.ia360_vault_offered || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); + for (const nid of offered) { + const { rows } = await pool.query( + `SELECT note_path, project_name FROM coexistence.ia360_vault_notes WHERE note_id = $1 LIMIT 1`, + [nid] + ); + const note = rows[0]; + if (!note) continue; + await pool.query( + `INSERT INTO coexistence.ia360_vault_links + (forgechat_contact_id, contact_number, note_path, project_name, estado, matched_by, confirmado_at) + VALUES ($1,$2,$3,$4,'rechazado','owner_reject',NOW()) + ON CONFLICT (forgechat_contact_id, note_path) DO UPDATE SET + estado='rechazado', matched_by='owner_reject', confirmado_at=NOW(), updated_at=NOW() + WHERE coexistence.ia360_vault_links.estado <> 'vinculado'`, + [contact.id, contact.contact_number, note.note_path, note.project_name || null] + ); + } + await mergeContactIa360State({ + waNumber: contact.wa_number, + contactNumber: contact.contact_number, + customFields: { ia360_vault_offered: '', ia360_vault_none: '1' }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_none_ack', + body: `Listo, no vinculé nada para ${targetContact}. Los candidatos quedan sellados como rechazados (no vuelvo a preguntar) y cuando haya documentos crearé nota nueva en Areas/CRM/contactos/.`, + targetContact, + ownerBudget: true, + }); + } catch (err) { + console.error('[ia360-vault] owner_vnone error:', err.message); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_none_ack', + body: `No pude sellar los rechazos para ${targetContact} (error interno). Vuelve a tocar "Ninguno, crear nuevo" en un momento.`, + targetContact, + ownerBudget: true, + }).catch(() => {}); + } +} + +// Comando del owner: "sincroniza a ". Resuelve el contacto con +// la misma tolerancia a typos del expediente, fuerza el auto-match por +// teléfono y, si no hay vínculo, propone candidatos por nombre con tarjeta. +// try/catch total: NUNCA queda mudo. +async function handleIa360OwnerVaultSync({ record, query }) { + const ownerText = (label, body) => sendIa360DirectText({ + record, toNumber: IA360_OWNER_NUMBER, label, body, ownerBudget: true, + }); + try { + await ensureIa360VaultTables(); + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + await ownerText('ia360_vault_sync_none', `No encontré a "${query}" ni como nombre ni como número.`); + return; + } + if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + await ownerText('ia360_vault_sync_ambiguous', + `Encontré varios contactos que coinciden con "${query}". ¿A cuál sincronizo?\n${list}\n\nMándame "sincroniza a " para elegirlo.`); + return; + } + const num = normalizePhone(target.candidates[0].contact_number); + const contact = await loadIa360VaultContactByNumber(num); + if (!contact) { + await ownerText('ia360_vault_sync_none', `No hay ficha de ${num} en contacts de ForgeChat; sin ficha no puedo vincular notas del vault.`); + return; + } + if (ia360VaultBlocklisted(contact.contact_number)) { + await ownerText('ia360_vault_sync_blocked', 'Ese número está en la blocklist del puente (bot/owner/QA); no se sincroniza.'); + return; + } + await ensureIa360VaultAutoLinks({ contact }); + const enriched = await enrichIa360ContactFromVault({ record, contact }); + if (enriched.notas.length) { + const display = contact.name || contact.profile_name || num; + const lines = [ + `Sincronizado ${display} (${num}): ${enriched.notas.length} nota(s) vinculada(s), ${enriched.facts} facts.`, + ...enriched.notas.map(n => `- ${n.note_path} → proyecto ${n.project_name || 'general'}`), + ]; + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_vault_synced', + body: lines.join('\n'), + targetContact: num, + ownerBudget: true, + }); + // "Si quieres vincular otra nota: sincroniza a " (ack del tap): + // si todavía quedan candidatos por nombre sin sellar, se ofrecen aquí + // mismo — sin esto el repeat-sync sería un callejón sin salida. + const remaining = await buildIa360VaultCandidates(contact); + if (remaining.length) await sendIa360VaultCandidateCard({ record, contact, candidates: remaining }); + return; + } + await sendIa360VaultCandidateCard({ record, contact }); + } catch (err) { + console.error('[ia360-vault] sync error:', err.message); + await ownerText('ia360_vault_sync_error', `No pude sincronizar "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`).catch(() => {}); + } +} + +// Hook al alta de contacto: el primer mensaje real de un número nuevo dispara +// UNA sola vez (flag ia360_vault_checked) el auto-match por teléfono y, si no +// hay vínculo pero sí candidatos por nombre, la tarjeta al owner. Sin tap del +// owner = cero sync. Fire-and-forget: jamás bloquea el inbound. +async function maybeIa360VaultIntake(record) { + await ensureIa360VaultTables(); + const { rows } = await pool.query( + `SELECT id, wa_number, contact_number, name, profile_name, custom_fields + FROM coexistence.contacts + WHERE wa_number = $1 AND contact_number = $2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const contact = rows[0]; + if (!contact) return; + if (String(contact.custom_fields?.ia360_vault_checked || '') === '1') return; + await mergeContactIa360State({ + waNumber: contact.wa_number, + contactNumber: contact.contact_number, + customFields: { ia360_vault_checked: '1' }, + }); + await ensureIa360VaultAutoLinks({ contact }); + const { rows: linked } = await pool.query( + `SELECT COUNT(*)::int AS n FROM coexistence.ia360_vault_links + WHERE forgechat_contact_id = $1 AND estado = 'vinculado'`, + [contact.id] + ); + if (linked[0].n > 0) { + await enrichIa360ContactFromVault({ record, contact }); + return; + } + const candidates = await buildIa360VaultCandidates(contact); + if (candidates.length) await sendIa360VaultCandidateCard({ record, contact, candidates }); +} + // ─── Bandeja de ideas del owner ───────────────────────────────────────────── // Una idea (comando del owner "idea: ", detección en conversación vía // Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una @@ -6825,6 +7410,15 @@ async function handleIa360LiteInteractive(record) { await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); return; } + // G-RAG: tarjeta de candidatos del vault — vincular nota o sellar rechazo. + if (ownerAction === 'owner_vlink') { + await handleIa360OwnerVaultLink({ record, targetContact, noteId: String(ownerPipe || '').replace(/\D/g, '') }); + return; + } + if (ownerAction === 'owner_vnone') { + await handleIa360OwnerVaultNone({ record, targetContact }); + return; + } if (ownerAction === 'owner_pipe') { await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); return; @@ -8472,6 +9066,19 @@ router.post('/webhook/whatsapp', async (req, res) => { continue; // no procesar como mensaje normal } } + // ── G-RAG: comando del owner "sincroniza a " ── + // Mismo patrón que el expediente: va ANTES del canary Brain v2 (el + // owner está en la allowlist y el canary haría continue). Vincula + // notas del vault al contacto y vuelca facts; nunca queda mudo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const syncMatch = String(record.message_body || '').trim().match(/^sincroniza(?:me)?\s+(?:a|al|con)\s+(.+)$/i); + if (syncMatch && syncMatch[1].trim()) { + await handleIa360OwnerVaultSync({ record, query: syncMatch[1].trim() }) + .catch(e => console.error('[ia360-vault] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } // ── CANARY Brain v2 (reversible, allowlist) ────────────────── // Antes de TODO el pipeline del monolito: si el remitente esta en la // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca @@ -8523,6 +9130,15 @@ router.post('/webhook/whatsapp', async (req, res) => { } } + // ── G-RAG: intake del vault al alta del contacto ───────────────── + // Fire-and-forget (sin continue): el auto-match con el vault corre + // UNA vez por contacto y no bloquea el flujo normal del mensaje. + if (record.direction === 'incoming' + && ['text', 'interactive', 'button'].includes(record.message_type) + && !ia360VaultBlocklisted(normalizePhone(record.contact_number))) { + maybeIa360VaultIntake(record).catch(e => console.error('[ia360-vault] intake error:', e.message)); + } + if (await handleIa360SharedContacts(record)) { continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal } diff --git a/backend/test/ia360NoSilenceRegression.test.js b/backend/test/ia360NoSilenceRegression.test.js index 7fde4a0..5797d7e 100644 --- a/backend/test/ia360NoSilenceRegression.test.js +++ b/backend/test/ia360NoSilenceRegression.test.js @@ -35,7 +35,9 @@ test('cliente activo beta memory dry-run must fall through to no-silence fallbac assert.match(betaBlock, /captureOnly: true/, 'la rama beta debe capturar memoria sin responder'); assert.match(betaBlock, /callIa360Agent/, 'la rama beta debe pedir respuesta real al agente'); assert.match(betaBlock, /ia360_cliente_activo_beta_agent_reply/, 'la respuesta real debe encolar al contacto'); - assert.match(betaBlock, /roleHint: buildIa360ClienteActivoBetaRoleHint/, 'el agente debe recibir el perfil ejecutivo'); + // G-BRAIN compuso el roleHint ([perfil ejecutivo, hint conversacional]); la + // intención es la misma: el perfil ejecutivo SIEMPRE llega al agente. + assert.ok(betaBlock.includes('buildIa360ClienteActivoBetaRoleHint(contactContext)'), 'el agente debe recibir el perfil ejecutivo'); assert.match(betaBlock, /handleIa360BotFailure/, 'sin agente debe haber holding + alerta + failure'); assert.match(betaBlock, /agente IA sin respuesta utilizable/); }); diff --git a/grag-e2e.sh b/grag-e2e.sh new file mode 100755 index 0000000..69dc79e --- /dev/null +++ b/grag-e2e.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E G-RAG — puente seguro AlekContenido ↔ RAG IA360 (2026-06-11) +# SIM 1 — Indexador: vault-bridge --scan puebla ia360_vault_notes (≥100 notas, +# teléfono de JR bien, Andrés sin teléfono, blocklist limpia). +# SIM 2 — JR: auto-match por teléfono + enriquecimiento (facts Konforthome) +# + el expediente del owner refleja los facts del vault. +# SIM 3 — Andrés: sin teléfono → tarjeta de candidatos al owner; dos taps +# owner_vlink sellan las 2 notas, cada fact en su jaula de proyecto. +# SIM 4 — JAULA multi-proyecto: el agente solo ve facts del proyecto activo +# (negativo duro: jamás cita la clave del otro proyecto). +# SIM 5 — Round-trip vinculado: docs_sync → append a la nota vinculada +# (marcador GRAG-RT-757 + idempotencia por id). +# SIM 6 — Drena las 2 filas reales (id 1 y 2) a Areas/CRM/contactos/ +# (entregable real, NO se borra). +# Uso: bash grag-e2e.sh (correr en el VPS desde /home/alek/stack/forgechat-poc). +# Solo números QA + owner sintético; todo egress al owner queda qa_sandboxed. +# ============================================================================ +set -uo pipefail + +WA="5213321594582" +OWNER="5213322638033" +PID_NUM="873315362541590" +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" +BRIDGE="/home/alek/stack/forgechat-poc/scripts/vault-bridge.js" +VAULT="/home/alek/vault-git-backup" +QA1="5219990000961" # QA jaula multi-proyecto (cliente activo beta sintético) +QA2="5219990000962" # QA round-trip vinculado (SIM 5) +JR_NUM="5213319706935" +JR_ID="899" +JR_NOTE="Areas/Proyectos/Konforthome/stakeholders/jose-ramon-reyes.md" +AND_NUM="5213321060293" +AND_ID="544" +AND_NOTE_CAM="Areas/Proyectos/Camiones-Selectos/stakeholders/andres-valencia.md" +AND_NOTE_META="Areas/Proyectos/0Prospectos/ArrendadoraMETA/stakeholders/andres-valencia.md" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3011}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado='$3' obtuvo='$2')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } +chk_nonempty(){ if [ -n "$2" ]; then ok "$1"; else bad "$1" "(vacío)" "no-vacío"; fi; } +chk_match(){ if echo "$2" | grep -qiE "$3"; then ok "$1"; else bad "$1" "$(echo "$2" | head -c 160)" "matchea:$3"; fi; } +chk_ge(){ if [ "${2:-0}" -ge "$3" ] 2>/dev/null; then ok "$1 ($2)"; else bad "$1" "${2:-0}" ">=$3"; fi; } + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status));}catch(e){process.stdout.write("ERR "+e.message);}});' + +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } + +text_payload(){ # $1=from $2=wamid $3=texto $4=nombre + printf '{"object":"whatsapp_business_account","entry":[{"id":"WABA","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"%s","phone_number_id":"%s"},"contacts":[{"wa_id":"%s","profile":{"name":"%s"}}],"messages":[{"from":"%s","id":"%s","timestamp":"%s","type":"text","text":{"body":"%s"}}]}}]}]}' \ + "$WA" "$PID_NUM" "$1" "$4" "$1" "$2" "$(ts)" "$3" +} + +list_reply_payload(){ # $1=from $2=wamid $3=reply_id $4=title — tap de lista (mismo envelope que text_payload) + printf '{"object":"whatsapp_business_account","entry":[{"id":"WABA","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"%s","phone_number_id":"%s"},"contacts":[{"wa_id":"%s","profile":{"name":"Alek"}}],"messages":[{"from":"%s","id":"%s","timestamp":"%s","type":"interactive","interactive":{"type":"list_reply","list_reply":{"id":"%s","title":"%s"}}}]}}]}]}' \ + "$WA" "$PID_NUM" "$1" "$1" "$2" "$(ts)" "$3" "$4" +} + +max_id(){ psql_q "SELECT COALESCE(MAX(id),0) FROM coexistence.chat_history"; } + +owner_text(){ # $1=texto — texto sintético del owner (wamid e2e ⇒ sandbox QA) + local W="wamid.e2e.grag.$(ts).$RANDOM" + post_webhook "$(text_payload "$OWNER" "$W" "$1" "Alek")" >/dev/null +} + +wait_owner_sandboxed(){ # $1=min_id $2=label_like $3=timeout_s → message_body + local deadline=$(( $(date +%s) + ${3:-60} )); local body="" + while [ "$(date +%s)" -lt "$deadline" ]; do + body=$(psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND to_number='$OWNER' AND status='qa_sandboxed' AND template_meta->>'label' LIKE '$2' AND id > $1 ORDER BY id ASC LIMIT 1") + [ -n "$body" ] && { printf '%s' "$body"; return 0; } + sleep 3 + done + printf '%s' "" +} + +wait_qa_reply(){ # $1=contacto $2=min_id $3=timeout_s → primer outgoing al contacto + local deadline=$(( $(date +%s) + ${3:-90} )); local body="" + while [ "$(date +%s)" -lt "$deadline" ]; do + body=$(psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$1' AND id > $2 ORDER BY id ASC LIMIT 1") + [ -n "$body" ] && { printf '%s' "$body"; return 0; } + sleep 5 + done + printf '%s' "" +} + +echo "=== STEP 0 — limpieza QA (números 521999*, facts y nota qa-grag) ===" +for q in "$QA1" "$QA2"; do + psql_q "DELETE FROM coexistence.ia360_vault_links WHERE contact_number='$q'" >/dev/null + psql_q "DELETE FROM coexistence.ia360_memory_facts WHERE contact_number='$q'" >/dev/null + psql_q "DELETE FROM coexistence.chat_history WHERE contact_number='$q'" >/dev/null + psql_q "DELETE FROM coexistence.deals WHERE contact_number='$q'" >/dev/null + psql_q "DELETE FROM coexistence.contacts WHERE contact_number='$q'" >/dev/null +done +psql_q "DELETE FROM coexistence.ia360_memory_facts WHERE fact_key LIKE 'alekcontenido:%qa-grag%' OR fact_key LIKE 'grag-e2e-%'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_docs_sync WHERE titulo='QA GRAG roundtrip'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_ideas WHERE texto LIKE '%GRAG-RT-757%'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_vault_links WHERE note_path LIKE '%qa-grag%'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_vault_notes WHERE note_path LIKE '%qa-grag%'" >/dev/null +rm -f "$VAULT/Areas/CRM/contactos/qa-grag-sandbox-contacto.md" +# Re-runnabilidad: los SIM 2/3 reconstruyen desde cero los artefactos G-RAG de +# los pilotos (links + facts del vault, NUNCA su memoria de WhatsApp). +psql_q "DELETE FROM coexistence.ia360_vault_links WHERE forgechat_contact_id IN ($JR_ID, $AND_ID)" >/dev/null +psql_q "DELETE FROM coexistence.ia360_memory_facts WHERE source='alekcontenido' AND contact_number IN ('$JR_NUM','$AND_NUM')" >/dev/null +psql_q "UPDATE coexistence.contacts SET custom_fields = COALESCE(custom_fields,'{}'::jsonb) || '{\"rag_enriched_at\":\"\",\"ia360_vault_checked\":\"\",\"ia360_vault_offered\":\"\",\"ia360_vault_none\":\"\"}'::jsonb WHERE contact_number IN ('$JR_NUM','$AND_NUM')" >/dev/null +echo " limpieza lista" + +echo "" +echo "=== SIM 1 — indexador: vault-bridge --scan ===" +node "$BRIDGE" --scan +N1=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_vault_notes WHERE missing_since IS NULL") +chk_ge "SIM1 notas vivas indexadas" "$N1" 100 +T1=$(psql_q "SELECT telefono_wa FROM coexistence.ia360_vault_notes WHERE note_path='$JR_NOTE'") +chk "SIM1 nota de JR con telefono_wa" "$T1" "$JR_NUM" +A1=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_vault_notes WHERE note_path IN ('$AND_NOTE_CAM','$AND_NOTE_META') AND telefono_wa IS NULL") +chk "SIM1 las 2 notas de Andrés sin telefono_wa" "$A1" "2" +B1=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_vault_notes WHERE telefono_wa='$WA' OR telefono_wa='$OWNER' OR telefono_wa LIKE '521999%'") +chk "SIM1 cero notas con teléfono de bot/owner/QA" "$B1" "0" +E1=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_vault_links WHERE note_path='$JR_NOTE' AND forgechat_contact_id <> $JR_ID") +chk "SIM1 nota de JR jamás vinculada a otro contacto" "$E1" "0" + +echo "" +echo "=== SIM 2 — JR: auto-match por teléfono + enriquecimiento + expediente ===" +MID=$(max_id) +owner_text "sincroniza a José Ramón" +ACK2=$(wait_owner_sandboxed "$MID" "ia360_vault_synced" 60) +chk_nonempty "SIM2 ack sincronizado (sandbox)" "$ACK2" +echo " ack: $(echo "$ACK2" | head -c 220)" +L2=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_vault_links WHERE forgechat_contact_id=$JR_ID AND note_path='$JR_NOTE' AND estado='vinculado' AND matched_by='telefono'") +chk "SIM2 link JR vinculado por teléfono" "$L2" "1" +F2=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_memory_facts WHERE source='alekcontenido' AND contact_number='$JR_NUM' AND project_name='Konforthome'") +chk_ge "SIM2 facts del vault en jaula Konforthome" "$F2" 2 +R2=$(psql_q "SELECT COALESCE(custom_fields->>'rag_enriched_at','') FROM coexistence.contacts WHERE contact_number='$JR_NUM' ORDER BY (wa_number='$WA') DESC LIMIT 1") +chk_nonempty "SIM2 rag_enriched_at sellado" "$R2" +MID=$(max_id) +owner_text "qué sabes de José Ramón" +DOS2=$(wait_owner_sandboxed "$MID" "owner_memory_dossier" 60) +chk_nonempty "SIM2 expediente del owner (sandbox)" "$DOS2" +chk_match "SIM2 expediente refleja fact del vault" "$DOS2" "Canal preferido|Konforthome" + +echo "" +echo "=== SIM 3 — Andrés: tarjeta de candidatos + 2 taps, cada fact en su jaula ===" +NID_CAM=$(psql_q "SELECT note_id FROM coexistence.ia360_vault_notes WHERE note_path='$AND_NOTE_CAM'") +NID_META=$(psql_q "SELECT note_id FROM coexistence.ia360_vault_notes WHERE note_path='$AND_NOTE_META'") +chk_nonempty "SIM3 note_id Camiones" "$NID_CAM" +chk_nonempty "SIM3 note_id META" "$NID_META" +MID=$(max_id) +owner_text "sincroniza a Andres" +CARD3=$(wait_owner_sandboxed "$MID" "ia360_vault_candidates" 60) +chk_nonempty "SIM3 tarjeta de candidatos (sandbox)" "$CARD3" +echo " tarjeta: $(echo "$CARD3" | head -c 220)" +chk_match "SIM3 tarjeta menciona la nota Camiones" "$CARD3" "Camiones" +chk_match "SIM3 tarjeta menciona la nota META" "$CARD3" "ArrendadoraMETA" +# Tap 1: vincular la nota de Camiones-Selectos +MID=$(max_id) +W3="wamid.e2e.grag.$(ts).$RANDOM" +post_webhook "$(list_reply_payload "$OWNER" "$W3" "owner_vlink:${AND_NUM}:${NID_CAM}" "andres valencia")" >/dev/null +ACK3=$(wait_owner_sandboxed "$MID" "ia360_vault_linked" 60) +chk_nonempty "SIM3 ack del vínculo Camiones" "$ACK3" +LC3=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_vault_links WHERE forgechat_contact_id=$AND_ID AND note_path='$AND_NOTE_CAM' AND estado='vinculado' AND matched_by='owner_tap'") +chk "SIM3 link Camiones sellado owner_tap" "$LC3" "1" +FC3=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_memory_facts WHERE source='alekcontenido' AND contact_number='$AND_NUM' AND project_name='Camiones-Selectos'") +chk_ge "SIM3 facts en jaula Camiones-Selectos" "$FC3" 1 +# Repetir el sincroniza: la tarjeta ya solo debe ofrecer la nota de META +MID=$(max_id) +owner_text "sincroniza a Andres" +SY3=$(wait_owner_sandboxed "$MID" "ia360_vault_synced" 60) +chk_nonempty "SIM3 ack synced tras el 1er vínculo" "$SY3" +CARD3B=$(wait_owner_sandboxed "$MID" "ia360_vault_candidates" 60) +chk_nonempty "SIM3 tarjeta restante (sandbox)" "$CARD3B" +chk_match "SIM3 tarjeta restante ofrece META" "$CARD3B" "ArrendadoraMETA" +if echo "$CARD3B" | grep -q "Camiones"; then bad "SIM3 tarjeta restante ya no ofrece Camiones" "menciona Camiones" "sin Camiones"; else ok "SIM3 tarjeta restante ya no ofrece Camiones"; fi +# Tap 2: vincular la nota de ArrendadoraMETA +MID=$(max_id) +W3B="wamid.e2e.grag.$(ts).$RANDOM" +post_webhook "$(list_reply_payload "$OWNER" "$W3B" "owner_vlink:${AND_NUM}:${NID_META}" "andres valencia")" >/dev/null +ACK3B=$(wait_owner_sandboxed "$MID" "ia360_vault_linked" 60) +chk_nonempty "SIM3 ack del vínculo META" "$ACK3B" +LM3=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_vault_links WHERE forgechat_contact_id=$AND_ID AND note_path='$AND_NOTE_META' AND estado='vinculado' AND matched_by='owner_tap'") +chk "SIM3 link META sellado owner_tap" "$LM3" "1" +FM3=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_memory_facts WHERE source='alekcontenido' AND contact_number='$AND_NUM' AND project_name='ArrendadoraMETA'") +chk_ge "SIM3 facts en jaula ArrendadoraMETA" "$FM3" 1 +LT3=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_vault_links WHERE forgechat_contact_id=$AND_ID AND estado='vinculado'") +chk "SIM3 dos links vinculados del contacto 544" "$LT3" "2" +XL3=$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_memory_facts WHERE source='alekcontenido' AND ((payload->>'note_path' LIKE '%ArrendadoraMETA%' AND project_name='Camiones-Selectos') OR (payload->>'note_path' LIKE '%Camiones-Selectos%' AND project_name='ArrendadoraMETA'))") +chk "SIM3 cero fuga inter-proyecto (fact_key/payload.note_path)" "$XL3" "0" + +echo "" +echo "=== SIM 4 — JAULA multi-proyecto: el agente no ve el otro proyecto ===" +# Contacto QA cliente activo beta con proyecto 'QA Jaula Alfa' (mismo +# custom_fields beta que usan gbrain-e2e.sh/glive-e2e.sh). +psql_q "INSERT INTO coexistence.contacts (wa_number, contact_number, name, tags, custom_fields) + VALUES ('$WA', '$QA1', 'QA Jaula Alfa Contacto', + '[\"cliente-activo-beta\",\"staged\"]'::jsonb, + '{\"staged\": true, \"ia360_cliente_activo_beta\": {\"schema\": \"cliente_activo_beta.v1\", \"contact_role\": \"Director de Finanzas (QA)\", \"project\": \"QA Jaula Alfa\", \"do_not_pitch\": true}}'::jsonb) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = EXCLUDED.name, + tags = EXCLUDED.tags, + custom_fields = coexistence.contacts.custom_fields || EXCLUDED.custom_fields" >/dev/null +psql_q "INSERT INTO coexistence.ia360_memory_facts (fact_key, contact_wa_number, contact_number, project_name, preference, confidence, status, last_seen_at) VALUES + ('grag-e2e-jaula-alfa-$QA1', '$WA', '$QA1', 'QA Jaula Alfa', 'Clave interna del proyecto: marca-alfa-757', 0.9, 'confirmado', NOW()), + ('grag-e2e-jaula-beta-$QA1', '$WA', '$QA1', 'QA Jaula Beta', 'Clave interna del proyecto: marca-beta-757', 0.9, 'confirmado', NOW())" >/dev/null +chk "SIM4 facts de 2 proyectos precargados" "$(psql_q "SELECT COUNT(*) FROM coexistence.ia360_memory_facts WHERE contact_number='$QA1'")" "2" +MID=$(max_id) +W4="wamid.e2e.grag.$(ts).$RANDOM" +post_webhook "$(text_payload "$QA1" "$W4" "¿Cuál es la clave interna de mi proyecto?" "QA Jaula Alfa Contacto")" >/dev/null +R4=$(wait_qa_reply "$QA1" "$MID" 90) +chk_nonempty "SIM4 reply del agente al QA" "$R4" +echo " reply: $(echo "$R4" | head -c 220)" +if echo "$R4" | grep -q "marca-beta-757"; then + bad "SIM4 negativo duro: jamás cita la clave Beta" "contiene marca-beta-757" "sin marca-beta-757" +else + ok "SIM4 negativo duro: jamás cita la clave Beta" +fi +J4=$(docker logs "$BE" --since 5m 2>&1 | grep '\[ia360-jaula\]' | grep -c 'proyecto=QA Jaula Alfa' || true) +chk_ge "SIM4 log [ia360-jaula] con proyecto=QA Jaula Alfa" "$J4" 1 + +echo "" +echo "=== SIM 5 — round-trip vinculado: docs_sync → nota del vault ===" +NOTE5="$VAULT/Areas/CRM/contactos/qa-grag-sandbox-contacto.md" +mkdir -p "$VAULT/Areas/CRM/contactos" +cat > "$NOTE5" <<'EOF' +--- +nombre: QA GRAG Sandbox +tipo: contacto +--- + +# QA GRAG Sandbox + +Nota sintética del harness G-RAG; se elimina al final del SIM 5. +EOF +psql_q "INSERT INTO coexistence.contacts (wa_number, contact_number, name, tags, custom_fields) + VALUES ('$WA', '$QA2', 'QA GRAG Sandbox', '[\"staged\"]'::jsonb, '{\"staged\": true}'::jsonb) + ON CONFLICT (wa_number, contact_number) DO NOTHING" >/dev/null +CID2=$(psql_q "SELECT id FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$QA2' LIMIT 1") +chk_nonempty "SIM5 contacto QA round-trip" "$CID2" +psql_q "INSERT INTO coexistence.ia360_vault_links (forgechat_contact_id, contact_number, note_path, project_name, estado, matched_by, confirmado_at) + VALUES ($CID2, '$QA2', 'Areas/CRM/contactos/qa-grag-sandbox-contacto.md', NULL, 'vinculado', 'owner_tap', NOW()) + ON CONFLICT (forgechat_contact_id, note_path) DO UPDATE SET estado='vinculado', matched_by='owner_tap', confirmado_at=NOW()" >/dev/null +# head -1: psql -tAc imprime el id Y la etiqueta "INSERT 0 1" en otra línea. +IDEA_ID=$(psql_q "INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) VALUES ('owner', '$QA2', 'QA GRAG roundtrip GRAG-RT-757', '{}'::jsonb) RETURNING id" | head -1) +SYNC_ID=$(psql_q "INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino, status) VALUES ($IDEA_ID, 'QA GRAG roundtrip', 'Marcador round-trip del harness: GRAG-RT-757', 'AlekContenido', 'queued') RETURNING id" | head -1) +chk_nonempty "SIM5 fila docs_sync queued" "$SYNC_ID" +node "$BRIDGE" --drain +if grep -q "GRAG-RT-757" "$NOTE5" 2>/dev/null; then ok "SIM5 la nota recibió el contenido (GRAG-RT-757)"; else bad "SIM5 contenido en la nota" "(sin marcador)" "GRAG-RT-757"; fi +if grep -q "ia360_docs_sync id=" "$NOTE5" 2>/dev/null; then ok "SIM5 marcador de idempotencia presente"; else bad "SIM5 marcador de idempotencia" "(sin marcador)" "ia360_docs_sync id="; fi +ST5=$(psql_q "SELECT status FROM coexistence.ia360_docs_sync WHERE id=$SYNC_ID") +chk "SIM5 fila marcada synced" "$ST5" "synced" +# Limpieza del SIM 5 (artefactos 100% sintéticos) +rm -f "$NOTE5" +psql_q "DELETE FROM coexistence.ia360_docs_sync WHERE id=$SYNC_ID" >/dev/null +psql_q "DELETE FROM coexistence.ia360_ideas WHERE id=$IDEA_ID" >/dev/null +psql_q "DELETE FROM coexistence.ia360_vault_links WHERE contact_number='$QA2'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_memory_facts WHERE contact_number='$QA2'" >/dev/null +psql_q "DELETE FROM coexistence.contacts WHERE contact_number='$QA2'" >/dev/null +psql_q "DELETE FROM coexistence.ia360_vault_notes WHERE note_path LIKE '%qa-grag%'" >/dev/null +echo " limpieza SIM 5 lista" + +echo "" +echo "=== SIM 6 — drenar las 2 filas reales (id 1 y 2) — entregable real ===" +# El drain del SIM 5 procesa TODO lo queued, así que aquí puede ser no-op; +# este SIM verifica el resultado (y NO borra: los archivos son el entregable). +node "$BRIDGE" --drain +ST61=$(psql_q "SELECT status FROM coexistence.ia360_docs_sync WHERE id=1") +ST62=$(psql_q "SELECT status FROM coexistence.ia360_docs_sync WHERE id=2") +chk "SIM6 fila 1 synced" "$ST61" "synced" +chk "SIM6 fila 2 synced" "$ST62" "synced" +F61=$(grep -lF "ia360_docs_sync id=1 -->" "$VAULT/Areas/CRM/contactos/"*.md 2>/dev/null | head -1) +chk_nonempty "SIM6 archivo de la fila 1 en Areas/CRM/contactos/" "$F61" +chk_match "SIM6 archivo fila 1 nombrado por su título" "$F61" "probar-template-de-ideas" +F62=$(grep -lF "ia360_docs_sync id=2 -->" "$VAULT/Areas/CRM/contactos/"*.md 2>/dev/null | head -1) +chk_nonempty "SIM6 archivo de la fila 2 en Areas/CRM/contactos/" "$F62" +chk_match "SIM6 archivo fila 2 es el mapa de cartera" "$F62" "mapa-de-cartera" + +echo "" +echo "=== RESULTADO G-RAG: PASS=$PASS FAIL=$FAIL ===" +[ "$FAIL" = "0" ] && exit 0 || exit 1 diff --git a/scripts/vault-bridge.js b/scripts/vault-bridge.js new file mode 100755 index 0000000..085c909 --- /dev/null +++ b/scripts/vault-bridge.js @@ -0,0 +1,442 @@ +#!/usr/bin/env node +'use strict'; +// ============================================================================ +// G-RAG vault-bridge — puente HOST entre AlekContenido (Obsidian) y la DB de +// ForgeChat (2026-06-11). El contenedor backend NO monta el vault: este script +// corre en el host y habla con Postgres vía `docker exec forgecrm-db psql`. +// Node 20, CERO dependencias npm. +// +// Modos: +// --scan indexa notas de stakeholders/CRM a ia360_vault_notes + auto-match +// por teléfono (jamás por nombre) a ia360_vault_links. +// --drain drena coexistence.ia360_docs_sync hacia el vault (round-trip): +// append a la nota VINCULADA del contacto o nota nueva en +// Areas/CRM/contactos/. +// (sin flag = ambos) +// ============================================================================ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const VAULT = '/home/alek/vault-git-backup'; +const BOT_NUMBER = '5213321594582'; +const OWNER_NUMBER = '5213322638033'; +const DOCKER = ['exec', '-i', 'forgecrm-db', 'psql', '-U', 'postgres', '-d', 'postgres', '-v', 'ON_ERROR_STOP=1']; +const CRM_CONTACTOS_DIR = 'Areas/CRM/contactos'; +const CRM_TEMPLATE = 'Areas/CRM/_templates/template-contacto.md'; + +// ─── Acceso a Postgres (docker exec, sin driver) ──────────────────────────── + +function psqlRun(sql) { + const res = spawnSync('docker', DOCKER, { input: sql, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (res.error) throw res.error; + if (res.status !== 0) { + throw new Error(`psql falló (status ${res.status}): ${String(res.stderr || '').trim().slice(0, 600)}`); + } + return String(res.stdout || ''); +} + +function psqlValue(sql) { + const res = spawnSync('docker', [...DOCKER, '-tAc', sql], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (res.error) throw res.error; + if (res.status !== 0) { + throw new Error(`psql falló (status ${res.status}): ${String(res.stderr || '').trim().slice(0, 600)}`); + } + return String(res.stdout || '').trim(); +} + +function queryJson(selectSql) { + const out = psqlValue(`SELECT COALESCE(json_agg(row_to_json(t)),'[]') FROM (${selectSql}) t`); + return JSON.parse(out || '[]'); +} + +// Dollar-quoting con tag único: el contenido de las notas es arbitrario; si el +// contenido contiene el tag, se regenera hasta que no colisione. +function dq(value) { + if (value == null) return 'NULL'; + const s = String(value); + let tag = 'VB7f3a'; + // El guard usa el tag SIN cierre: cubre contenido que TERMINA en el tag + // parcial (concatenado con el cierre rompería el literal y abortaría el lote). + while (s.includes(`$${tag}`)) tag = `VB${Math.random().toString(36).slice(2, 8)}`; + return `$${tag}$${s}$${tag}$`; +} + +// ─── Normalizaciones (espejo EXACTO de las del backend, webhook.js G-RAG) ─── + +function normalizeVaultPhone(raw) { + const d = String(raw || '').replace(/\D/g, ''); + if (d.length === 10) return `521${d}`; + if (d.length === 12 && d.startsWith('52')) return `521${d.slice(2)}`; + if (d.length === 13 && d.startsWith('521')) return d; + return null; +} + +function blocklisted(num) { + const n = String(num || ''); + return n === BOT_NUMBER || n === OWNER_NUMBER || /^521999/.test(n); +} + +// Misma normalización que ia360NormalizeNameForMatch del backend: minúsculas, +// sin acentos (NFD), letras repetidas colapsadas, espacios colapsados. +function normalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +function cleanWikilinks(s) { + return String(s || '').replace(/\[\[([^\]]+)\]\]/g, '$1').trim(); +} + +// ─── DDL (idéntico a la migración; idempotente) ───────────────────────────── + +function ensureTables() { + psqlRun(` +CREATE TABLE IF NOT EXISTS coexistence.ia360_vault_notes ( + note_id BIGSERIAL UNIQUE, + note_path TEXT PRIMARY KEY, + nombre TEXT, + nombre_normalizado TEXT, + telefono_wa TEXT, + project_name TEXT, + rol TEXT, + empresa TEXT, + frontmatter JSONB NOT NULL DEFAULT '{}'::jsonb, + contenido TEXT, + file_mtime TIMESTAMPTZ, + indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + missing_since TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS ia360_vault_notes_tel_idx + ON coexistence.ia360_vault_notes (telefono_wa) WHERE telefono_wa IS NOT NULL; +CREATE INDEX IF NOT EXISTS ia360_vault_notes_nombre_idx + ON coexistence.ia360_vault_notes (nombre_normalizado); +CREATE TABLE IF NOT EXISTS coexistence.ia360_vault_links ( + id BIGSERIAL PRIMARY KEY, + forgechat_contact_id BIGINT NOT NULL, + contact_number TEXT NOT NULL, + note_path TEXT NOT NULL, + project_name TEXT, + estado TEXT NOT NULL CHECK (estado IN ('vinculado','rechazado')), + matched_by TEXT NOT NULL CHECK (matched_by IN ('telefono','owner_tap','owner_reject')), + confirmado_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (forgechat_contact_id, note_path) +); +CREATE UNIQUE INDEX IF NOT EXISTS ia360_vault_links_note_vinculado_uidx + ON coexistence.ia360_vault_links (note_path) WHERE estado = 'vinculado'; +CREATE INDEX IF NOT EXISTS ia360_vault_links_contact_idx + ON coexistence.ia360_vault_links (contact_number, estado); +`); +} + +// ─── SCAN: filesystem → ia360_vault_notes ─────────────────────────────────── + +const EXCLUDE_RE = /(_bmad-output|_snapshots|_templates|_views|node_modules|\.obsidian|\.git)/; + +function walkMarkdown(dir, out) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (EXCLUDE_RE.test(full)) continue; + if (e.isDirectory()) walkMarkdown(full, out); + else if (e.isFile() && e.name.endsWith('.md')) out.push(full); + } + return out; +} + +function relVaultPath(abs) { + return path.relative(VAULT, abs).split(path.sep).join('/'); +} + +// Solo stakeholders de proyectos y todo Areas/CRM (las exclusiones ya +// filtraron _templates, _views, etc.). +function isScanTarget(rel) { + return /^Areas\/Proyectos\/.+\/stakeholders\/[^/]+\.md$/.test(rel) + || /^Areas\/CRM\/.+\.md$/.test(rel); +} + +// Frontmatter YAML plano entre los dos primeros '---': solo pares clave:valor +// de una línea; quita comillas envolventes y [[wikilinks]]; las listas y +// colecciones quedan como string crudo. +function parseFrontmatter(raw) { + const m = String(raw || '').match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!m) return { frontmatter: {}, body: String(raw || '') }; + const fm = {}; + for (const line of m[1].split(/\r?\n/)) { + const kv = line.match(/^([A-Za-zÀ-ÿ][\wÀ-ÿ -]*?)\s*:\s*(.*)$/); + if (!kv) continue; + const key = kv[1].trim(); + let val = kv[2].trim(); + if ((val.startsWith('"') && val.endsWith('"') && val.length >= 2) + || (val.startsWith("'") && val.endsWith("'") && val.length >= 2)) { + val = val.slice(1, -1); + } + fm[key] = cleanWikilinks(val); + } + return { frontmatter: fm, body: m[2] || '' }; +} + +// project_name desde el path: 0Prospectos/ y Detenidos/ son carpetas +// agrupadoras (el proyecto es ); Areas/CRM → NULL (fact general de persona). +function deriveProjectName(rel) { + let m = rel.match(/^Areas\/Proyectos\/0Prospectos\/([^/]+)\//); + if (m) return m[1]; + m = rel.match(/^Areas\/Proyectos\/Detenidos\/([^/]+)\//); + if (m) return m[1]; + m = rel.match(/^Areas\/Proyectos\/([^/]+)\//); + if (m) return m[1]; + return null; +} + +function pickKey(fm, ...keys) { + for (const k of keys) { + if (fm[k] != null && String(fm[k]).trim() !== '') return String(fm[k]).trim(); + } + return null; +} + +function buildNoteRow(abs) { + const rel = relVaultPath(abs); + const raw = fs.readFileSync(abs, 'utf8'); + const { frontmatter, body } = parseFrontmatter(raw); + const stat = fs.statSync(abs); + let telefono = normalizeVaultPhone(frontmatter.telefono_wa); + // Jamás auto-match con bot/owner/QA: un teléfono en blocklist se guarda NULL. + if (telefono && blocklisted(telefono)) telefono = null; + const nombre = pickKey(frontmatter, 'nombre', 'nombre_completo') + || path.basename(abs, '.md').replace(/-/g, ' '); + return { + note_path: rel, + nombre, + nombre_normalizado: normalizeNameForMatch(nombre), + telefono_wa: telefono, + project_name: deriveProjectName(rel), + rol: pickKey(frontmatter, 'rol', 'cargo', 'rol_principal', 'rol_en_meta'), + empresa: cleanWikilinks(pickKey(frontmatter, 'empresa', 'organizacion', 'organización') || '') || null, + frontmatter, + contenido: body, + file_mtime: stat.mtime.toISOString(), + }; +} + +function upsertNotes(rows) { + const BATCH = 50; + for (let i = 0; i < rows.length; i += BATCH) { + const values = rows.slice(i, i + BATCH).map(n => `(${[ + dq(n.note_path), + dq(n.nombre), + dq(n.nombre_normalizado), + dq(n.telefono_wa), + dq(n.project_name), + dq(n.rol), + dq(n.empresa), + `${dq(JSON.stringify(n.frontmatter))}::jsonb`, + dq(n.contenido), + `${dq(n.file_mtime)}::timestamptz`, + ].join(', ')})`).join(',\n'); + psqlRun(` +INSERT INTO coexistence.ia360_vault_notes + (note_path, nombre, nombre_normalizado, telefono_wa, project_name, rol, empresa, frontmatter, contenido, file_mtime) +VALUES +${values} +ON CONFLICT (note_path) DO UPDATE SET + nombre = EXCLUDED.nombre, + nombre_normalizado = EXCLUDED.nombre_normalizado, + telefono_wa = EXCLUDED.telefono_wa, + project_name = EXCLUDED.project_name, + rol = EXCLUDED.rol, + empresa = EXCLUDED.empresa, + frontmatter = EXCLUDED.frontmatter, + contenido = EXCLUDED.contenido, + file_mtime = EXCLUDED.file_mtime, + indexed_at = NOW(), + missing_since = NULL; +`); + } +} + +function markMissing(seenPaths) { + if (!seenPaths.length) return; + const inList = seenPaths.map(p => dq(p)).join(', '); + psqlRun(` +UPDATE coexistence.ia360_vault_notes + SET missing_since = NOW() + WHERE missing_since IS NULL + AND note_path NOT IN (${inList}); +`); +} + +// AUTO-MATCH global por teléfono: mismo statement que el del backend +// (ensureIa360VaultAutoLinks) pero sin filtro de contacto. El guard +// COUNT(DISTINCT)=1 evita que un teléfono apunte a 2 contactos; el NOT EXISTS +// respeta vínculos vivos y rechazos sellados; la blocklist queda en SQL. +function autoMatchAll() { + const out = psqlValue(` +INSERT INTO coexistence.ia360_vault_links + (forgechat_contact_id, contact_number, note_path, project_name, estado, matched_by, confirmado_at) +SELECT c.id, c.contact_number, n.note_path, n.project_name, 'vinculado', 'telefono', NOW() + FROM coexistence.ia360_vault_notes n + JOIN coexistence.contacts c ON c.contact_number = n.telefono_wa + WHERE n.missing_since IS NULL + AND n.telefono_wa IS NOT NULL + AND c.contact_number NOT LIKE '521999%' + AND c.contact_number NOT IN ('${BOT_NUMBER}', '${OWNER_NUMBER}') + AND (SELECT COUNT(DISTINCT c2.id) FROM coexistence.contacts c2 WHERE c2.contact_number = n.telefono_wa) = 1 + AND NOT EXISTS (SELECT 1 FROM coexistence.ia360_vault_links l + WHERE l.note_path = n.note_path + AND (l.estado = 'vinculado' OR l.forgechat_contact_id = c.id)) +ON CONFLICT (forgechat_contact_id, note_path) DO NOTHING +RETURNING note_path`); + // psql -tAc imprime las filas del RETURNING Y la etiqueta "INSERT 0 N": + // se descarta la etiqueta para no inflar el conteo. + return out ? out.split('\n').filter(l => l && !/^INSERT \d+ \d+$/.test(l)).length : 0; +} + +function runScan() { + const candidates = [ + ...walkMarkdown(path.join(VAULT, 'Areas', 'Proyectos'), []), + ...walkMarkdown(path.join(VAULT, 'Areas', 'CRM'), []), + ]; + const rows = []; + for (const abs of candidates) { + const rel = relVaultPath(abs); + if (!isScanTarget(rel)) continue; + try { + rows.push(buildNoteRow(abs)); + } catch (err) { + console.error(`[vault-bridge] nota ilegible ${rel}: ${err.message}`); + } + } + upsertNotes(rows); + markMissing(rows.map(n => n.note_path)); + const newLinks = autoMatchAll(); + console.log(`[vault-bridge] scan: ${rows.length} notas indexadas, ${newLinks} auto-links nuevos`); +} + +// ─── DRAIN: ia360_docs_sync → vault (round-trip) ──────────────────────────── + +function slugify(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/g, ''); +} + +function syncSection(row) { + const fecha = new Date().toISOString().slice(0, 10); + return `\n\n## Sync IA360 — ${row.titulo || 'sin título'} (${fecha})\n\n${row.contenido || ''}\n\n\n`; +} + +// Nota nueva en Areas/CRM/contactos/ desde el template de contacto; los +// placeholders que no podemos llenar quedan vacíos (no inventar datos). +function createNoteFromTemplate(absPath, displayName, contactNumber) { + let content = ''; + const templatePath = path.join(VAULT, CRM_TEMPLATE); + if (fs.existsSync(templatePath)) { + content = fs.readFileSync(templatePath, 'utf8') + .replace(/\{\{nombre_completo\}\}/g, displayName) + .replace(/\{\{nombre completo\}\}/g, displayName) + .replace(/\{\{\+52 33 XXXX XXXX\}\}/g, contactNumber || '') + .replace(/\{\{[^}]*\}\}/g, ''); + } else { + // Sin template (vault parcial): encabezado mínimo, el append agrega lo demás. + content = `# ${displayName}\n`; + } + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content, 'utf8'); +} + +function drainRow(row) { + const num = row.contact_number + ? (normalizeVaultPhone(row.contact_number) || String(row.contact_number).replace(/\D/g, '')) + : null; + let relPath = null; + let modo = 'nueva'; + if (num) { + // POR VÍNCULO, JAMÁS por nombre: solo la nota vinculada más reciente. + const links = queryJson( + `SELECT note_path FROM coexistence.ia360_vault_links + WHERE contact_number = ${dq(num)} AND estado = 'vinculado' + ORDER BY confirmado_at DESC NULLS LAST LIMIT 1` + ); + if (links.length) { + relPath = links[0].note_path; + modo = 'vinculada'; + } + } + if (!relPath) { + const base = slugify(row.titulo) || (num ? `contacto-${num}` : `doc-${row.id}`); + relPath = `${CRM_CONTACTOS_DIR}/${base}.md`; + } + // note_path viene de la DB: contención dura, jamás escribir fuera del vault. + const absPath = path.resolve(VAULT, relPath); + if (absPath !== VAULT && !absPath.startsWith(VAULT + path.sep)) { + throw new Error(`note_path fuera del vault: ${relPath}`); + } + const marker = ``; + const exists = fs.existsSync(absPath); + if (exists && fs.readFileSync(absPath, 'utf8').includes(marker)) { + // Idempotencia: la sección ya está escrita; solo se marca synced. + } else { + if (!exists && modo === 'nueva') { + createNoteFromTemplate(absPath, row.titulo || (num ? `Contacto ${num}` : `Documento ${row.id}`), num); + } else if (!exists) { + // Nota vinculada sin archivo en disco: el append la recrea en su path. + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + } + fs.appendFileSync(absPath, syncSection(row), 'utf8'); + } + psqlRun(`UPDATE coexistence.ia360_docs_sync SET status='synced', synced_at=NOW() WHERE id=${Number(row.id)};`); + console.log(`[vault-bridge] drain: fila ${row.id} → ${relPath} (${modo})`); +} + +function runDrain() { + const rows = queryJson( + `SELECT s.id, s.titulo, s.contenido, i.contact_number + FROM coexistence.ia360_docs_sync s + LEFT JOIN coexistence.ia360_ideas i ON i.id = s.idea_id + WHERE s.status = 'queued' + ORDER BY s.id` + ); + for (const row of rows) { + try { + drainRow(row); + } catch (err) { + console.error(`[vault-bridge] drain: fila ${row.id} ERROR: ${err.message}`); + try { + psqlRun(`UPDATE coexistence.ia360_docs_sync SET status='error' WHERE id=${Number(row.id)};`); + } catch (updErr) { + console.error(`[vault-bridge] drain: no pude marcar error la fila ${row.id}: ${updErr.message}`); + } + } + } + if (!rows.length) console.log('[vault-bridge] drain: sin filas queued'); +} + +// ─── CLI ──────────────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2); + const doScan = args.includes('--scan') || args.length === 0; + const doDrain = args.includes('--drain') || args.length === 0; + if (!doScan && !doDrain) { + console.error('[vault-bridge] uso: vault-bridge.js [--scan] [--drain]'); + process.exit(2); + } + ensureTables(); + if (doScan) runScan(); + if (doDrain) runDrain(); +} + +main(); From 8618979aa903375da6f50d3bc448b3496be89c94 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Mon, 15 Jun 2026 18:50:00 +0000 Subject: [PATCH 31/39] =?UTF-8?q?feat(ia360):=20G6=20=E2=80=94=20botones?= =?UTF-8?q?=20de=20opener=20(Si,cuentame/Ahora=20no)=20ya=20no=20mueren=20?= =?UTF-8?q?en=20el=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los quick-reply de los templates frios del opener llegan con id=titulo visible y sin payload estable; handleRevenueOsButton y el resolver de alias seq_* los rutean solo bajo su estado gateado, asi que fuera de ese estado caian al fallback generico (log: unhandled interactive reply id=si, cuentame). - nuevo modulo puro src/routes/ia360OpenerReply.js: normalizeOpenerReplyId (minusculas+sin acentos+sin puntuacion+trim) y classifyOpenerReply. - getInteractiveReplyId delega en extractInteractiveReplyId del modulo. - handler de ultimo recurso ANTES del fallback: afirmativo -> REVENUE_OS_COPY.paso2 (demo/onboarding) + estado calificacion; negativo -> REVENUE_OS_COPY.ahoraNo (cierre cortes) + nutricion suave; ambos avisan al owner. - test/ia360OpenerButtons.test.js (5 casos, sin DB). - TODO: buildIa360OpenerInteractive debe emitir payload ESTABLE (no titulo). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/ia360OpenerReply.js | 86 ++++++++++++ backend/src/routes/webhook.js | 122 +++++++++++++++--- backend/test/ia360NoSilenceRegression.test.js | 13 ++ backend/test/ia360OpenerButtons.test.js | 105 +++++++++++++++ 4 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 backend/src/routes/ia360OpenerReply.js create mode 100644 backend/test/ia360OpenerButtons.test.js diff --git a/backend/src/routes/ia360OpenerReply.js b/backend/src/routes/ia360OpenerReply.js new file mode 100644 index 0000000..7632aff --- /dev/null +++ b/backend/src/routes/ia360OpenerReply.js @@ -0,0 +1,86 @@ +// ── G6: ruteo de los botones de los openers (quick-reply de template) ──────── +// Los quick-reply de los templates fríos del opener llegan SIN payload estable: +// Meta manda el id = el TÍTULO visible del botón ("Sí, cuéntame" / "Ahora no"). +// getInteractiveReplyId ya baja a minúsculas y hace trim, pero conserva acentos +// y puntuación, así que "si, cuentame" (lo que viste en el log) no empata con +// ninguna ruta gateada por estado y el botón muere en [ia360-fallback]. +// +// Este módulo es PURO (sin DB, sin red): normaliza el id y lo clasifica como +// afirmativo / negativo / null. Lo usa webhook.js como ÚLTIMO recurso, después +// de que todos los handlers gateados por estado declinaron, para que un opener +// huérfano siempre reciba un siguiente paso coherente en vez del fallback. +// +// TODO(G6+): cuando buildIa360OpenerInteractive emita payload ESTABLE (ids del +// tipo `opener::si` en vez del título), este clasificador por texto deja de +// ser necesario para los openers nuevos; mantenerlo solo como red de seguridad +// para los templates frios viejos ya enviados. + +// Quita acentos, baja a minúsculas, colapsa espacios y recorta puntuación de los +// bordes. " Sí, Cuéntame! " -> "si, cuentame". +function normalizeOpenerReplyId(raw) { + return String(raw || '') + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') // marcas diacríticas (acentos, diéresis) + .toLowerCase() + .replace(/[¡!¿?.]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// Lexicón normalizado (sin acentos) de los títulos afirmativos y negativos que +// emiten los openers persona-first y el template Revenue OS. Debe permanecer en +// sintonía con IA360_SEQ_ALIAS_AFFIRMATIVE / IA360_SEQ_ALIAS_NEGATIVE de +// webhook.js (allí se conservan con acentos para el match exacto temprano). +const OPENER_AFFIRMATIVE = new Set([ + 'si cuentame', 'si, cuentame', + 'si cuentame mas', 'si, cuentame mas', + 'si preguntame', 'si, preguntame', + 'si mandalo', 'si, mandalo', + 'si compartelo', 'si, compartelo', + 'si a ver', 'si, a ver', + 'si te cuento', 'si, te cuento', + 'si hay un tema', 'si, hay un tema', + 'me interesa', 'si me interesa', 'si, me interesa', +]); +const OPENER_NEGATIVE = new Set([ + 'ahora no', 'por ahora no', 'no por ahora', +]); + +// Devuelve 'affirmative' | 'negative' | null. null => no es un botón de opener +// reconocible (que siga su curso al fallback genérico). +function classifyOpenerReply(raw) { + const key = normalizeOpenerReplyId(raw); + if (!key) return null; + if (OPENER_NEGATIVE.has(key)) return 'negative'; + if (OPENER_AFFIRMATIVE.has(key)) return 'affirmative'; + return null; +} + +// Parse PURO del id de un reply interactivo desde el raw_payload de Meta. +// getInteractiveReplyId() de webhook.js delega aquí para que el test ejercite la +// misma extracción que corre en producción. Baja a minúsculas y hace trim +// (conserva acentos — la normalización fuerte la hace classifyOpenerReply). +function extractInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + +module.exports = { + normalizeOpenerReplyId, + classifyOpenerReply, + extractInteractiveReplyId, + OPENER_AFFIRMATIVE, + OPENER_NEGATIVE, +}; diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 628bb3d..524439e 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -8,6 +8,7 @@ const { enqueueMediaDownload } = require('../queue/mediaQueue'); const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); const { enqueueSend } = require('../queue/sendQueue'); const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); +const { classifyOpenerReply, extractInteractiveReplyId } = require('./ia360OpenerReply'); const router = Router(); @@ -2509,21 +2510,10 @@ async function bookIa360Slot({ record, start, end }) { } } +// Delega en el módulo puro ./ia360OpenerReply (mismo parse, sin DB) para que los +// tests ejerciten exactamente la extracción que corre en producción. function getInteractiveReplyId(record) { - try { - const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; - const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; - const interactive = msg?.interactive; - if (interactive) { - if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); - if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); - } - if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); - if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); - } catch (_) { - // ignore malformed/non-JSON payloads; fallback to visible title - } - return ''; + return extractInteractiveReplyId(record); } @@ -8731,6 +8721,67 @@ async function handleIa360LiteInteractive(record) { return; } + // ── G6: ÚLTIMO RECURSO PARA BOTONES DE OPENER (quick-reply sin payload) ───── + // Los quick-reply de los templates fríos del opener llegan con id = el TÍTULO + // visible ("Sí, cuéntame" / "Ahora no") y SIN payload estable. handleRevenueOsButton + // y el resolver de alias seq_* los rutean SOLO cuando el contacto está en el + // estado correcto (apertura_sent / persona-first enviado). Si su proceso no + // calza ese estado, declinaban y el botón moría en [ia360-fallback] + // (log: "unhandled interactive reply id=si, cuentame"). Aquí, ya que todos los + // handlers gateados declinaron, clasificamos el id normalizado (minúsculas, sin + // acentos, sin puntuación, trim) y damos un siguiente paso COHERENTE en vez del + // fallback genérico. Reusamos el copy de Revenue OS (demo/onboarding). + // TODO(G6+): buildIa360OpenerInteractive (~L3474) debe emitir payload ESTABLE + // (p.ej. `opener::si` / `opener::no`) en vez de depender del título; + // cuando lo haga, este clasificador por texto queda solo como red de seguridad + // para los templates fríos viejos ya enviados. + const openerKind = classifyOpenerReply(replyId || answer); + if (openerKind) { + try { + const safeName = record.contact_name || record.contact_number; + if (openerKind === 'affirmative') { + // "Sí, cuéntame" → abre la conversación de demo/onboarding con la pregunta + // de calificación (PASO 2 de Revenue OS) y deja estado para que su texto + // libre siguiente llegue al agente. NO es el fallback genérico. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_opener_si_recovery' }, + }).catch(e => console.error('[ia360-opener] state affirmative:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_opener_si_recovery', body: REVENUE_OS_COPY.paso2 }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_opener_si_recovery', + body: `Alek, ${safeName} (${record.contact_number}) tocó "Sí, cuéntame" de un opener (id: ${replyId || answer || '-'}) sin estado activo. Le respondí con la pregunta de calificación para abrir la demo; quedó en "calificación".`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-opener] owner notice si:', e.message)); + } else { + // "Ahora no" → cierre cortés (mismo copy que Revenue OS) + nutrición suave. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_opener_ahora_no_recovery' }, + }).catch(e => console.error('[ia360-opener] state negative:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_opener_ahora_no_recovery', body: REVENUE_OS_COPY.ahoraNo }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_opener_ahora_no_recovery', + body: `Alek, ${safeName} (${record.contact_number}) tocó "Ahora no" de un opener (id: ${replyId || answer || '-'}). Cerré cortés y lo dejé en nutrición suave.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-opener] owner notice no:', e.message)); + } + } catch (openerErr) { + console.error('[ia360-opener] recovery handler error:', openerErr.message); + } + return; + } + // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo o // malformado). Los ids seq_* del catálogo y los quick replies de template con @@ -8852,10 +8903,28 @@ async function handleBrainV2Canary(record) { return; } } + const isOwnerDirect = normalizePhone(record.contact_number) === IA360_OWNER_NUMBER && !forceActor; + if (isOwnerDirect) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'brainv2_owner_operator_local', + body: [ + 'Te leo como owner. Brain v2 sigue en canary, pero ya no te voy a mandar el error genérico.', + '', + 'Comandos útiles:', + '- `/sim ` para probar cómo respondería a un contacto.', + '- `idea: ` para mandar algo a la bandeja de ideas.', + '- `qué sabes de ` para consultar memoria.', + '- `sincroniza a ` para vincular notas del vault.' + ].join('\n') + }); + return; + } console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); if (!out) { - await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2 no devolvió respuesta para la simulación. La ruta owner directa sigue disponible; reviso el workflow antes de otro intento.' }); return; } const branch = out.branch || out.route || 'fallback'; @@ -8874,8 +8943,12 @@ async function handleBrainV2Canary(record) { await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); return; } - // owner_operator / system_excluded / fallback => SIN reply (por diseno). - console.log('[brain-v2-canary] sin reply (branch=%s)', branch); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'brainv2_no_route', + body: `Brain v2 devolvió la rama "${branch}" sin respuesta final. No te dejo mudo; usa /sim para probar como contacto o dime qué flujo quieres revisar.` + }); } // G-LIVE QA-GUARD (incidente José Ramón): detecta payloads SINTÉTICOS del harness @@ -8954,9 +9027,20 @@ router.post('/webhook/whatsapp', async (req, res) => { // produced phantom "Status: delivered" bubbles. If no matching message // exists (e.g. an app-sent message we don't track), this is a no-op. if (r.message_type === 'status') { + const statusErrors = Array.isArray(r.errors) && r.errors.length > 0 + ? r.errors.map((e) => e?.message || e?.title || e?.code || JSON.stringify(e)).filter(Boolean).join('; ') + : null; await client.query( - `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, - [r.status, r.message_id] + `UPDATE coexistence.chat_history + SET status = $1, + raw_payload = COALESCE($3::jsonb, raw_payload), + error_message = CASE + WHEN $1 IN ('failed', 'error') THEN COALESCE($4, error_message) + WHEN $1 IN ('sent', 'delivered', 'read') THEN NULL + ELSE error_message + END + WHERE message_id = $2`, + [r.status, r.message_id, r.raw_payload || null, statusErrors] ); continue; } diff --git a/backend/test/ia360NoSilenceRegression.test.js b/backend/test/ia360NoSilenceRegression.test.js index 5797d7e..e6747f7 100644 --- a/backend/test/ia360NoSilenceRegression.test.js +++ b/backend/test/ia360NoSilenceRegression.test.js @@ -105,3 +105,16 @@ test('datos vivos del portal: handoff explícito, nunca inventar listas', () => const handoffBlock = src.slice(handoffIdx, handoffIdx + 900); assert.match(handoffBlock, /alreadyResponded: sentHandoff === true/); }); + +test('owner directo en Brain v2 canary no responde holding generico', () => { + const handlerStart = src.indexOf('async function handleBrainV2Canary(record)'); + assert.notEqual(handlerStart, -1, 'falta handleBrainV2Canary'); + const handlerEnd = src.indexOf('// G-LIVE QA-GUARD', handlerStart); + const handler = src.slice(handlerStart, handlerEnd); + assert.match(handler, /isOwnerDirect/, 'la ruta owner directa debe estar explícita'); + assert.match(handler, /brainv2_owner_operator_local/, 'owner directo debe recibir respuesta operativa local'); + assert.ok( + handler.indexOf('brainv2_owner_operator_local') < handler.indexOf('callBrainV2'), + 'owner directo no debe depender del workflow Brain v2 incompleto' + ); +}); diff --git a/backend/test/ia360OpenerButtons.test.js b/backend/test/ia360OpenerButtons.test.js new file mode 100644 index 0000000..bfed7be --- /dev/null +++ b/backend/test/ia360OpenerButtons.test.js @@ -0,0 +1,105 @@ +// G6 (2026-06-15): los botones de los openers de WhatsApp (quick-reply de +// template) llegan con id = el TÍTULO visible ("Sí, cuéntame" / "Ahora no") y +// SIN payload estable. Antes morían en [ia360-fallback] +// (log real: "unhandled interactive reply id=si, cuentame"). Este test demuestra +// que getInteractiveReplyId (vía el parse puro extractInteractiveReplyId) + el +// clasificador de openers YA NO los dejan caer al fallback, sino al handler de +// recuperación nuevo que rutea afirmativo→demo/onboarding y negativo→cierre. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + classifyOpenerReply, + normalizeOpenerReplyId, + extractInteractiveReplyId, +} = require('../src/routes/ia360OpenerReply'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'webhook.js'), 'utf8'); + +// Construye un raw_payload de Meta como el que reenvía n8n, con un button_reply +// cuyo id es el título del botón (lo que hace Meta con los quick-reply sin payload). +function metaButtonReplyRecord(buttonId, buttonTitle) { + return { + raw_payload: JSON.stringify({ + entry: [{ changes: [{ value: { messages: [{ + type: 'interactive', + interactive: { type: 'button_reply', button_reply: { id: buttonId, title: buttonTitle || buttonId } }, + }] } }] }], + }), + }; +} + +test('normalizeOpenerReplyId: minúsculas, sin acentos, sin puntuación, trim', () => { + assert.equal(normalizeOpenerReplyId(' Sí, Cuéntame! '), 'si, cuentame'); + assert.equal(normalizeOpenerReplyId('AHORA NO'), 'ahora no'); + assert.equal(normalizeOpenerReplyId('¿Sí, cuéntame más?'), 'si, cuentame mas'); + assert.equal(normalizeOpenerReplyId(''), ''); +}); + +test('getInteractiveReplyId (parse) extrae el id del button_reply tal cual llega de Meta', () => { + // El caso EXACTO del log: el botón "Sí, cuéntame" llega con id="si, cuentame". + const rec = metaButtonReplyRecord('si, cuentame', 'Sí, cuéntame'); + const replyId = extractInteractiveReplyId(rec); + assert.equal(replyId, 'si, cuentame', 'el id debe llegar en minúsculas+trim como en producción'); + + // Y un payload basura no tumba el parse (devuelve ''). + assert.equal(extractInteractiveReplyId({ raw_payload: 'no-json' }), ''); +}); + +test('los openers afirmativos/negativos clasifican (ya NO caen al fallback)', () => { + const affirmatives = [ + 'si, cuentame', // <- el id EXACTO del log + 'Sí, cuéntame', + 'SÍ, CUÉNTAME', + 'sí, cuéntame más', + ' Sí, Cuéntame! ', + 'me interesa', + ]; + for (const raw of affirmatives) { + assert.equal(classifyOpenerReply(raw), 'affirmative', `"${raw}" debe rutear como afirmativo`); + } + + const negatives = ['ahora no', 'Ahora no', 'AHORA NO', 'por ahora no', 'no por ahora']; + for (const raw of negatives) { + assert.equal(classifyOpenerReply(raw), 'negative', `"${raw}" debe rutear como negativo (cierre cortés)`); + } + + // Texto que NO es un botón de opener sigue su curso (null => fallback genérico). + for (const raw of ['hola', 'cuanto cuesta', 'wa_schedule', '']) { + assert.equal(classifyOpenerReply(raw), null, `"${raw}" no es un opener reconocible`); + } +}); + +test('end-to-end del parse: payload del log → afirmativo, no fallback', () => { + const rec = metaButtonReplyRecord('si, cuentame', 'Sí, cuéntame'); + const replyId = extractInteractiveReplyId(rec); + assert.equal(classifyOpenerReply(replyId), 'affirmative'); + + const recNo = metaButtonReplyRecord('Ahora no', 'Ahora no'); + assert.equal(classifyOpenerReply(extractInteractiveReplyId(recNo)), 'negative'); +}); + +test('webhook.js cablea el handler de recuperación ANTES del fallback global', () => { + // El require del módulo puro existe. + assert.match(src, /require\('\.\/ia360OpenerReply'\)/, 'falta el require del módulo de openers'); + // getInteractiveReplyId delega en el parse puro. + assert.match(src, /function getInteractiveReplyId\(record\)\s*\{\s*return extractInteractiveReplyId\(record\);/); + + const recoveryIdx = src.indexOf('const openerKind = classifyOpenerReply(replyId || answer);'); + const fallbackIdx = src.indexOf('[ia360-fallback] unhandled interactive reply'); + assert.notEqual(recoveryIdx, -1, 'falta el handler de recuperación de openers'); + assert.notEqual(fallbackIdx, -1, 'falta el fallback global'); + assert.ok(recoveryIdx < fallbackIdx, 'el handler de recuperación debe ir ANTES del fallback'); + + // Entre el handler de recuperación y el fallback hay un `return;` que corta el + // flujo, así que un opener clasificado NUNCA alcanza el fallback. + const between = src.slice(recoveryIdx, fallbackIdx); + assert.match(between, /if \(openerKind\) \{/); + assert.match(between, /REVENUE_OS_COPY\.paso2/, 'afirmativo reusa el copy de demo/onboarding'); + assert.match(between, /REVENUE_OS_COPY\.ahoraNo/, 'negativo reusa el cierre cortés'); + assert.match(between, /ia360_opener_si_recovery/); + assert.match(between, /ia360_opener_ahora_no_recovery/); + assert.match(between, /\n\s*return;\s*\n/, 'el handler debe cortar con return antes del fallback'); +}); From 756aea111dafce6fd2712405e39da8c14f065b71 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Mon, 15 Jun 2026 19:17:21 +0000 Subject: [PATCH 32/39] =?UTF-8?q?feat(ia360):=20G5=20=E2=80=94=20circuit?= =?UTF-8?q?=20breaker=20+=20alerta=20de=20pago=20(131042)=20en=20el=20webh?= =?UTF-8?q?ook=20de=20status=20-=20detecta=20status=20failed=20con=20codig?= =?UTF-8?q?os=20de=20pago/elegibilidad=20(131042=20y=20familia)=20en=20el?= =?UTF-8?q?=20loop=20de=20statusRecords=20-=20marca=20cuenta=20bloqueada-p?= =?UTF-8?q?or-pago=20y=20alerta=20al=20owner=20UNA=20vez=20(anti-spam=20po?= =?UTF-8?q?r=20cuenta/dia)=20-=20auto-recuperacion=20solo=20con=20entrega?= =?UTF-8?q?=20facturable=20(sesion=20NO=20limpia=20el=20flag)=20-=20sendQu?= =?UTF-8?q?eue:=20skipRetry=20para=20payment=5Fblocked=20(no=20quema=20rei?= =?UTF-8?q?ntentos)=20-=20gate=20de=20pausa=20de=20templates=20NO=20activa?= =?UTF-8?q?do=20(evita=20deadlock=20de=20auto-recuperacion),=20documentado?= =?UTF-8?q?=20TODO=20-=20test=207/7=20(sin=20deps=20externas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/queue/sendQueue.js | 8 +- backend/src/routes/webhook.js | 57 +++++ backend/src/services/accountHealth.js | 6 + backend/src/services/paymentCircuitBreaker.js | 195 ++++++++++++++++++ backend/test/paymentCircuitBreaker.test.js | 150 ++++++++++++++ 5 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 backend/src/services/paymentCircuitBreaker.js create mode 100644 backend/test/paymentCircuitBreaker.test.js diff --git a/backend/src/queue/sendQueue.js b/backend/src/queue/sendQueue.js index 3f0851b..33cfe80 100644 --- a/backend/src/queue/sendQueue.js +++ b/backend/src/queue/sendQueue.js @@ -115,8 +115,12 @@ async function processJob(job) { } catch (err) { const cls = classifyMetaError(err); await markAccountHealth(account.id, cls, err.message); - // Don't retry auth failures — they'll fail every time until token is fixed - if (cls === 'invalid_token') { + // Don't retry auth failures — they'll fail every time until token is fixed. + // G5: tampoco reintentar bloqueos de cuenta por pago/elegibilidad (131042 y + // familia): Meta ya dijo bloqueo, reintentar quema los 4 intentos en vano. + // (El 131042 suele llegar como webhook de status, no como excepción síncrona; + // este skipRetry cubre el caso en que Meta SÍ rechace de forma síncrona.) + if (cls === 'invalid_token' || cls === 'payment_blocked') { err.skipRetry = true; } throw err; diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 628bb3d..994e642 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -8,6 +8,7 @@ const { enqueueMediaDownload } = require('../queue/mediaQueue'); const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); const { enqueueSend } = require('../queue/sendQueue'); const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); +const { evaluatePaymentStatus } = require('../services/paymentCircuitBreaker'); const router = Router(); @@ -5324,6 +5325,55 @@ async function sendIa360DirectText({ record, toNumber, body, label, targetContac } } +// ─── G5: Circuit breaker de PAGO / elegibilidad de Meta ───────────────────── +// Engancha en el procesamiento de webhooks de STATUS. El 131042 NO llega como +// excepción en el envío síncrono (Meta devuelve accepted+wamid); llega DESPUÉS +// como status "failed" con errors[].code=131042. Por eso el breaker vive aquí, +// no sólo en el envío. La LÓGICA (clasificación, flag por cuenta, anti-spam) está +// en services/paymentCircuitBreaker.js; esta función sólo traduce la decisión en +// una alerta al owner vía sendIa360DirectText (UNA vez por bloqueo/día/código). +async function handleIa360PaymentCircuitBreaker(record) { + const decision = evaluatePaymentStatus(record); + if (decision.action === 'none') return; + + if (decision.action === 'recover') { + console.log('[ia360-payment-cb] cuenta %s RECUPERADA (status=%s, facturable entregado) — flag limpiado', + decision.accountKey, record.status); + // Aviso de recuperación: también es un mensaje de sesión al owner (entrega + // aunque la cuenta estuviera bloqueada para templates). + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_payment_recovered', + body: '✅ WhatsApp volvió a entregar templates: el bloqueo por pago/elegibilidad de Meta quedó resuelto. Reanudo envíos normales.', + }).catch(e => console.error('[ia360-payment-cb] aviso recuperación falló:', e.message)); + return; + } + + // action === 'block' + if (!decision.shouldAlert) { + console.log('[ia360-payment-cb] cuenta %s ya bloqueada code=%s — alerta suprimida (anti-spam)', + decision.accountKey, decision.code); + return; + } + + const cuenta = record.wa_number || decision.accountKey; + const body = + `🚨 *WhatsApp bloqueó tus envíos* (cuenta ${cuenta}).\n\n` + + `Meta marcó un mensaje como *failed* por *${decision.title}* (código ${decision.code}).\n\n` + + `Esto bloquea los *templates* (mensajes iniciados por el negocio). Los mensajes de sesión (respuestas dentro de 24 h) siguen funcionando.\n\n` + + (decision.href ? `Resuélvelo aquí:\n${decision.href}` : 'Revisa el Billing Hub de Meta para liquidar el saldo pendiente.'); + + const sent = await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_payment_block_${decision.code}`, + body, + }).catch(e => { console.error('[ia360-payment-cb] alerta owner falló:', e.message); return false; }); + console.log('[ia360-payment-cb] ALERTA owner cuenta=%s code=%s alerta#%d enviada=%s', + decision.accountKey, decision.code, decision.alertCount, sent); +} + // ─── Expediente del owner: "qué sabes de " ────────────────── // Comando read-only del owner: arma un expediente con los facts y eventos de // coexistence.ia360_memory_* para un contacto, resuelto por número o por @@ -9214,6 +9264,13 @@ router.post('/webhook/whatsapp', async (req, res) => { } catch (triggerErr) { console.error('[webhook] Status trigger evaluation error:', triggerErr.message); } + // G5: circuit breaker de pago/elegibilidad (131042 y familia). Va fuera + // de la transacción de inserción para no enviar dentro del BEGIN/COMMIT. + try { + await handleIa360PaymentCircuitBreaker(record); + } catch (cbErr) { + console.error('[ia360-payment-cb] error:', cbErr.message); + } } } diff --git a/backend/src/services/accountHealth.js b/backend/src/services/accountHealth.js index 16cc820..2c33e75 100644 --- a/backend/src/services/accountHealth.js +++ b/backend/src/services/accountHealth.js @@ -4,6 +4,7 @@ // up silently. const pool = require('../db'); +const { isAccountBlockRetryCode } = require('./paymentCircuitBreaker'); /** * Classify an error from Meta and persist it on the account row. @@ -44,6 +45,11 @@ async function markAccountHealth(accountId, status, message = null) { function classifyMetaError(err) { if (!err) return 'unknown_error'; if (err.status === 401 || err.metaError?.code === 190) return 'invalid_token'; + // G5: bloqueo a nivel CUENTA por pago/elegibilidad/deshabilitación (131042 y + // familia + 368). Set compartido con el circuit breaker (paymentCircuitBreaker) + // para que breaker y skipRetry no se desincronicen. Reintentar aquí quema los 4 + // intentos en vano porque Meta ya dijo bloqueo de cuenta. + if (isAccountBlockRetryCode(err.metaError?.code)) return 'payment_blocked'; if (err.status === 429 || err.metaError?.code === 4 || err.metaError?.code === 80007) return 'rate_limited'; return 'unknown_error'; } diff --git a/backend/src/services/paymentCircuitBreaker.js b/backend/src/services/paymentCircuitBreaker.js new file mode 100644 index 0000000..916c5c4 --- /dev/null +++ b/backend/src/services/paymentCircuitBreaker.js @@ -0,0 +1,195 @@ +// G5 — Circuit breaker de PAGO / elegibilidad de Meta. +// +// PROBLEMA (incidente 2026-06-13): la WABA cayó en "Business eligibility +// payment issue" (código Meta 131042 — pagos sin liquidar). Meta sigue +// ACEPTANDO el envío del template de forma síncrona (HTTP 2xx + wamid), así que +// NO hay excepción en el envío. El bloqueo llega DESPUÉS, como webhook de status +// "failed" con errors[].code=131042. Resultado: los templates murieron 2 días en +// silencio (los mensajes de sesión seguían llegando, lo que enmascaró el fallo). +// +// Este módulo es lógica PURA y testeable: clasifica el código de error, mantiene +// el flag de bloqueo por cuenta (en memoria) y decide cuándo alertar al owner +// con anti-spam (dedup por cuenta+código+día). El envío real del aviso vive en +// webhook.js (sendIa360DirectText); aquí sólo se decide QUÉ hacer. +// +// El estado vive en memoria a propósito: si el container se reinicia, el flag se +// pierde y, ante el próximo status 131042, se vuelve a alertar UNA vez — lo cual +// es deseable (informa de nuevo) y mantiene el módulo hermético/testeable sin DB. + +// ── Clasificación de códigos ──────────────────────────────────────────────── +// Códigos Meta que indican un bloqueo a NIVEL CUENTA por pago/elegibilidad. +// 131042 — "Business eligibility payment issue" (pagos sin liquidar). CONFIRMADO +// en el incidente; es el principal y el que se prioriza. +// 131045 — error de elegibilidad/registro a nivel cuenta (misma familia). +// 131031 — la cuenta ha sido bloqueada (account locked) — bloqueo de cuenta. +// +// EXCLUIDO a propósito: 131026 ("Message Undeliverable"). Ese código es +// específico del DESTINATARIO (no está en WhatsApp, no puede recibir, etc.), NO +// un bloqueo de cuenta. Disparar el breaker con 131026 produciría falsos +// positivos que enmudecerían una cuenta sana ante un solo número malo. Por eso +// el circuit breaker NO lo trata como bloqueo de pago/elegibilidad. +const PAYMENT_BLOCK_CODES = new Set([131042, 131045, 131031]); + +// Set usado por sendQueue (vía accountHealth.classifyMetaError) para SALTAR los +// reintentos: además de los de pago/elegibilidad incluye 368 (bloqueo temporal +// por violación de políticas). Si Meta YA dijo bloqueo de cuenta de forma +// síncrona, reintentar quema los 4 intentos en vano. Fuente única para que el +// breaker (webhook) y el skipRetry (envío) no se desincronicen. +const ACCOUNT_BLOCK_RETRY_CODES = new Set([131042, 131045, 131031, 368]); + +// Categorías de pricing que indican una conversación FACTURABLE / iniciada por +// el negocio (template). La auto-recuperación sólo confía en la entrega exitosa +// de una de ÉSTAS — nunca en un mensaje de sesión/servicio, porque los de sesión +// siguen entregando DURANTE el bloqueo (eso fue justo lo que enmascaró el +// incidente) y limpiarían el flag de inmediato, anulando el breaker. +const BILLABLE_CATEGORIES = new Set([ + 'business_initiated', 'marketing', 'utility', 'authentication', +]); + +/** + * @param {number|string} code código de error de Meta + * @returns {'payment_block'|null} + */ +function classifyPaymentBlockError(code) { + if (code == null) return null; + const n = parseInt(code, 10); + if (Number.isNaN(n)) return null; + return PAYMENT_BLOCK_CODES.has(n) ? 'payment_block' : null; +} + +/** ¿El código justifica skipRetry en sendQueue (bloqueo de cuenta)? */ +function isAccountBlockRetryCode(code) { + if (code == null) return false; + const n = parseInt(code, 10); + return !Number.isNaN(n) && ACCOUNT_BLOCK_RETRY_CODES.has(n); +} + +// ── Estado en memoria ─────────────────────────────────────────────────────── +// Map +const _blocked = new Map(); + +function _dayKey(now) { + // YYYY-MM-DD en UTC (estable para el dedup diario, independiente de la zona). + return now.toISOString().slice(0, 10); +} + +function _isBillableStatus(record) { + const p = record && record.pricing; + if (!p) return false; + if (p.billable === true) return true; + return BILLABLE_CATEGORIES.has(p.category); +} + +/** + * Evalúa un record de status del webhook y decide la acción del breaker. + * PURA respecto al envío (no envía nada): sólo muta el estado interno y devuelve + * la decisión para que el caller (webhook.js) alerte al owner si corresponde. + * + * @param {object} record record de status (message_type==='status') con + * { status, errors[], pricing, phone_number_id, wa_number } + * @param {Date} [now] inyectable para test; default new Date() + * @returns {{ + * action: 'block'|'recover'|'none', + * accountKey: string, + * shouldAlert: boolean, + * code?: number, href?: string, title?: string, message?: string, + * alertCount?: number, + * }} + */ +function evaluatePaymentStatus(record, now = new Date()) { + const accountKey = String( + (record && (record.phone_number_id || record.wa_number)) || 'default' + ); + + // Auto-recuperación: una entrega FACTURABLE exitosa (template entregado/leído) + // significa que Meta volvió a aceptar conversaciones de pago → limpiar flag. + if ((record.status === 'delivered' || record.status === 'read') && _isBillableStatus(record)) { + const wasBlocked = _blocked.delete(accountKey); + return { action: wasBlocked ? 'recover' : 'none', accountKey, shouldAlert: wasBlocked }; + } + + if (record.status !== 'failed') return { action: 'none', accountKey, shouldAlert: false }; + + const errs = Array.isArray(record.errors) ? record.errors : []; + const payErr = errs.find(e => classifyPaymentBlockError(e && e.code)); + if (!payErr) return { action: 'none', accountKey, shouldAlert: false }; + + const code = parseInt(payErr.code, 10); + const href = payErr.href || (payErr.error_data && payErr.error_data.href) || null; + const title = payErr.title || payErr.message || 'Business eligibility payment issue'; + const message = (payErr.error_data && payErr.error_data.details) || payErr.message || title; + const today = _dayKey(now); + + const prev = _blocked.get(accountKey); + // Anti-spam: alertar SOLO si es un bloqueo nuevo, o si cambió el código, o si + // ya pasó a otro día (re-aviso diario de un bloqueo que persiste). Cada fallo + // adicional del MISMO bloqueo en el MISMO día NO re-alerta. + const shouldAlert = !prev || prev.code !== code || prev.lastAlertDay !== today; + const alertCount = (prev && prev.alertCount ? prev.alertCount : 0) + (shouldAlert ? 1 : 0); + + _blocked.set(accountKey, { + code, href, title, message, + since: prev && prev.since ? prev.since : now.toISOString(), + lastAlertDay: shouldAlert ? today : (prev ? prev.lastAlertDay : today), + alertCount, + }); + + return { action: 'block', accountKey, shouldAlert, code, href, title, message, alertCount }; +} + +/** ¿La cuenta está marcada como bloqueada por pago/elegibilidad? */ +function isPaymentBlocked(accountKey) { + return _blocked.has(String(accountKey)); +} + +// ── GATE #4 (NO ACTIVADO a propósito): pausar el envío de TEMPLATES ───────── +// isPaymentBlocked() existe para que, en el futuro, sendQueue pueda RECHAZAR +// templates mientras el flag esté activo (los mensajes de sesión deben seguir). +// NO se cablea hoy por DOS riesgos concretos: +// +// 1) DEADLOCK de auto-recuperación: el flag SÓLO se limpia cuando llega un +// status 'delivered/read' de una conversación FACTURABLE (un template). Si +// pausamos TODOS los templates, ninguno se envía, ninguno se entrega, y el +// flag jamás se limpia solo → la cuenta queda muda para templates incluso +// después de que el owner pague. La recuperación depende de dejar pasar al +// menos un template para "sondear". +// 2) FALSO POSITIVO: el flag es en memoria y por phone_number_id; un único +// 131042 espurio dejaría sin templates a una cuenta sana hasta el reinicio. +// +// El valor del breaker (alerta instantánea + skipRetry para no quemar reintentos) +// ya se entrega sin este gate. Si se activa más adelante, hace falta: (a) excluir +// del gate un "template sonda" periódico, o (b) un TTL/limpieza manual del flag, +// para no caer en el deadlock anterior. + +/** ¿Debe pausarse el envío de templates? (Gate #4 — hoy SIEMPRE false: ver arriba.) */ +function shouldPauseTemplates(/* accountKey */) { + return false; // TODO(gate#4): activar sólo con template-sonda o TTL (evita deadlock). +} + +/** Estado del bloqueo (o null) — útil para diagnóstico/UI. */ +function getPaymentBlock(accountKey) { + return _blocked.get(String(accountKey)) || null; +} + +/** Limpia el flag manualmente (recuperación forzada). @returns {boolean} estaba bloqueado */ +function clearPaymentBlock(accountKey) { + return _blocked.delete(String(accountKey)); +} + +/** Sólo para tests: reinicia todo el estado en memoria. */ +function _resetForTest() { + _blocked.clear(); +} + +module.exports = { + classifyPaymentBlockError, + isAccountBlockRetryCode, + evaluatePaymentStatus, + isPaymentBlocked, + shouldPauseTemplates, + getPaymentBlock, + clearPaymentBlock, + PAYMENT_BLOCK_CODES, + ACCOUNT_BLOCK_RETRY_CODES, + _resetForTest, +}; diff --git a/backend/test/paymentCircuitBreaker.test.js b/backend/test/paymentCircuitBreaker.test.js new file mode 100644 index 0000000..da43e41 --- /dev/null +++ b/backend/test/paymentCircuitBreaker.test.js @@ -0,0 +1,150 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + classifyPaymentBlockError, + isAccountBlockRetryCode, + evaluatePaymentStatus, + isPaymentBlocked, + clearPaymentBlock, + _resetForTest, +} = require('../src/services/paymentCircuitBreaker'); + +// Status record tal como lo produce parseMetaPayload para el webhook de status +// del incidente real (chat_history id 2100): "Business eligibility payment issue" +// código 131042. errors[] viene del raw_payload de Meta sin parafrasear. +function failedStatus131042() { + return { + message_id: 'wamid.HBgNNTIxMzMyMjYzODAzMxUCABEYFENFMkU5MEIyODNEQ0Y3NjU2MkQyAA==', + phone_number_id: '873315362541590', + wa_number: '5213321594582', + contact_number: '5213322638033', + direction: 'outgoing', + message_type: 'status', + status: 'failed', + pricing: null, + errors: [{ + code: 131042, + href: 'https://business.facebook.com/billing_hub/accounts/details/?business_id=382618582575064&asset_id=1190241942503057&wizard_name=PAY_NOW&account_type=whatsapp-business-account', + title: 'Business eligibility payment issue', + message: 'Business eligibility payment issue', + error_data: { details: 'Message failed to send because your WhatsApp Business account has unsettled payments.' }, + }], + }; +} + +// Entrega exitosa de un template (conversación FACTURABLE) → señal de recuperación. +function deliveredBillableStatus() { + return { + message_id: 'wamid.RECOVERED', + phone_number_id: '873315362541590', + wa_number: '5213321594582', + contact_number: '5213322638033', + direction: 'outgoing', + message_type: 'status', + status: 'delivered', + pricing: { billable: true, pricing_model: 'CBP', category: 'business_initiated' }, + errors: null, + }; +} + +test('clasificación: 131042 (y familia) es payment_block; 131026 (per-destinatario) NO', () => { + assert.equal(classifyPaymentBlockError(131042), 'payment_block'); + assert.equal(classifyPaymentBlockError(131045), 'payment_block'); + assert.equal(classifyPaymentBlockError(131031), 'payment_block'); + // 131026 = "Message Undeliverable" (destinatario), NO bloqueo de cuenta: + // excluido a propósito para no enmudecer una cuenta sana por un solo número malo. + assert.equal(classifyPaymentBlockError(131026), null); + assert.equal(classifyPaymentBlockError(0), null); + assert.equal(classifyPaymentBlockError(undefined), null); +}); + +test('webhook status 131042: marca el flag + alerta al owner UNA vez + NO spamea al 2do fallo', () => { + _resetForTest(); + const accountKey = '873315362541590'; + assert.equal(isPaymentBlocked(accountKey), false); + + // 1er fallo 131042 → bloquea, marca flag y DEBE alertar. + const first = evaluatePaymentStatus(failedStatus131042(), new Date('2026-06-15T15:51:36Z')); + assert.equal(first.action, 'block'); + assert.equal(first.shouldAlert, true, 'el primer 131042 debe alertar'); + assert.equal(first.code, 131042); + assert.match(first.href, /billing_hub/); + assert.equal(first.alertCount, 1); + assert.equal(isPaymentBlocked(accountKey), true, 'el flag de bloqueo quedó marcado'); + + // 2do fallo del MISMO bloqueo, mismo día → NO debe re-alertar (anti-spam). + const second = evaluatePaymentStatus(failedStatus131042(), new Date('2026-06-15T15:52:10Z')); + assert.equal(second.action, 'block'); + assert.equal(second.shouldAlert, false, 'el 2do fallo del mismo bloqueo NO debe alertar'); + assert.equal(second.alertCount, 1, 'el contador de alertas no se incrementa'); + assert.equal(isPaymentBlocked(accountKey), true); + + // 3er fallo, MISMO bloqueo pero al DÍA SIGUIENTE → re-aviso diario permitido. + const nextDay = evaluatePaymentStatus(failedStatus131042(), new Date('2026-06-16T09:00:00Z')); + assert.equal(nextDay.shouldAlert, true, 'un bloqueo que persiste re-alerta al día siguiente'); + assert.equal(nextDay.alertCount, 2); +}); + +test('auto-recuperación: entrega facturable exitosa limpia el flag y avisa UNA vez', () => { + _resetForTest(); + const accountKey = '873315362541590'; + evaluatePaymentStatus(failedStatus131042(), new Date('2026-06-15T15:51:36Z')); + assert.equal(isPaymentBlocked(accountKey), true); + + const recovered = evaluatePaymentStatus(deliveredBillableStatus(), new Date('2026-06-15T18:00:00Z')); + assert.equal(recovered.action, 'recover'); + assert.equal(recovered.shouldAlert, true); + assert.equal(isPaymentBlocked(accountKey), false, 'el flag se limpió tras la entrega facturable'); + + // Una 2da entrega facturable, ya sin bloqueo, NO genera otro aviso. + const again = evaluatePaymentStatus(deliveredBillableStatus(), new Date('2026-06-15T18:05:00Z')); + assert.equal(again.action, 'none'); + assert.equal(again.shouldAlert, false); +}); + +test('un mensaje de SESIÓN entregado NO limpia el flag (sólo conversación facturable)', () => { + _resetForTest(); + const accountKey = '873315362541590'; + evaluatePaymentStatus(failedStatus131042(), new Date('2026-06-15T15:51:36Z')); + assert.equal(isPaymentBlocked(accountKey), true); + + // delivered de un mensaje de servicio/sesión (sin pricing facturable): + // durante el bloqueo los de sesión SIGUEN entregando; no deben limpiar el flag. + const sessionDelivered = { ...deliveredBillableStatus(), pricing: { billable: false, category: 'service' } }; + const r = evaluatePaymentStatus(sessionDelivered, new Date('2026-06-15T16:00:00Z')); + assert.equal(r.action, 'none'); + assert.equal(isPaymentBlocked(accountKey), true, 'el flag sigue activo: una sesión no es señal de recuperación'); +}); + +test('un status failed SIN código de pago no dispara el breaker', () => { + _resetForTest(); + const noPay = failedStatus131042(); + noPay.errors = [{ code: 131026, title: 'Message Undeliverable' }]; + const r = evaluatePaymentStatus(noPay, new Date('2026-06-15T15:51:36Z')); + assert.equal(r.action, 'none'); + assert.equal(r.shouldAlert, false); + assert.equal(isPaymentBlocked('873315362541590'), false); +}); + +test('gate #2 sendQueue: códigos de bloqueo de cuenta marcan skipRetry', () => { + // Fuente única compartida con accountHealth.classifyMetaError: si el código + // está aquí, classifyMetaError devuelve 'payment_blocked' y sendQueue pone + // skipRetry (no quema los 4 reintentos). El 131042 síncrono o familia + 368. + assert.equal(isAccountBlockRetryCode(131042), true); + assert.equal(isAccountBlockRetryCode(131045), true); + assert.equal(isAccountBlockRetryCode(131031), true); + assert.equal(isAccountBlockRetryCode(368), true); + // 131026 (per-destinatario) y nulos NO deben saltar reintentos. + assert.equal(isAccountBlockRetryCode(131026), false); + assert.equal(isAccountBlockRetryCode(190), false); + assert.equal(isAccountBlockRetryCode(undefined), false); +}); + +// Limpieza manual idempotente. +test('clearPaymentBlock es idempotente', () => { + _resetForTest(); + evaluatePaymentStatus(failedStatus131042(), new Date('2026-06-15T15:51:36Z')); + assert.equal(clearPaymentBlock('873315362541590'), true); + assert.equal(clearPaymentBlock('873315362541590'), false); +}); From aaa7a2aac7839148edd9f7b8836302e5cea58ddc Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 16 Jun 2026 02:00:06 +0000 Subject: [PATCH 33/39] =?UTF-8?q?feat(ia360):=20G8=20P0=20=E2=80=94=20deal?= =?UTF-8?q?s=20de=20aliado/referido=20BNI=20van=20a=20su=20pipeline=20(Par?= =?UTF-8?q?tners/Aliados=20id=206)=20-=20syncIa360Deal=20acepta=20pipeline?= =?UTF-8?q?Name=20(default=20Revenue=20generico:=20no=20rompe=20lo=20exist?= =?UTF-8?q?ente)=20-=20ia360DealRouting.js:=20relationshipContext=20aliado?= =?UTF-8?q?=5Fsocio/referido=5Fbni=20->=20Partners/Aliados;=20mapea=20stag?= =?UTF-8?q?es=20a=20los=20reales=20del=20pipeline=206=20-=20callsites=20Re?= =?UTF-8?q?venue/100M=20intactos;=20test=208/8=20sin=20deps=20externas=20-?= =?UTF-8?q?=20gaps=20P1=20documentados:=20Fit=20identificado=20y=20Diagnos?= =?UTF-8?q?tico=20compartido=20sin=20callsite=20(faltan=20secuencias=20Blu?= =?UTF-8?q?eprint/Propuesta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/ia360DealRouting.js | 72 ++++++++++ backend/src/routes/webhook.js | 49 ++++++- .../test/ia360AliadoPipelineRouting.test.js | 123 ++++++++++++++++++ 3 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 backend/src/routes/ia360DealRouting.js create mode 100644 backend/test/ia360AliadoPipelineRouting.test.js diff --git a/backend/src/routes/ia360DealRouting.js b/backend/src/routes/ia360DealRouting.js new file mode 100644 index 0000000..f117f43 --- /dev/null +++ b/backend/src/routes/ia360DealRouting.js @@ -0,0 +1,72 @@ +// G8 (2026-06-16): ruteo de pipeline por relación para el journey de aliado BNI. +// +// Problema (auditoría 05-Agentes/auditorias/2026-06-15-pipeline-aliado-bni-vs-journey.md, +// gap P0-1): `syncIa360Deal` hardcodeaba el pipeline genérico +// 'IA360 WhatsApp Revenue Pipeline' (id 2), así que TODO deal —incluidos los de +// aliado/referido BNI— caía ahí, y el pipeline propio "Partners / Aliados (BNI)" +// (id 6) quedaba en 0 deals. El journey de partner no se medía ni avanzaba con +// lógica. +// +// Este módulo es PURO (sin dependencias: ni express, ni pool) para que el test +// pueda requerirlo aunque no haya node_modules instalado. `webhook.js` lo consume. +// +// IDs reales (verificados por SELECT en coexistence.pipelines, 2026-06-16): +// 2 = IA360 WhatsApp Revenue Pipeline (genérico) 6 = Partners / Aliados (BNI) + +const IA360_DEFAULT_PIPELINE_NAME = 'IA360 WhatsApp Revenue Pipeline'; +const IA360_PARTNERS_PIPELINE_NAME = 'Partners / Aliados (BNI)'; + +// Relaciones que pertenecen al journey BNI (catálogo persona-first de webhook.js: +// persona_aliado.relationshipContext = 'aliado_socio', +// persona_referido.relationshipContext = 'referido_bni'). +const IA360_PARTNER_RELATIONSHIPS = new Set(['aliado_socio', 'referido_bni']); + +// Traducción de stage del Revenue pipeline (semántica COMPARTIDA por los handlers +// persona-first, p. ej. handleIa360SequenceReply) → stage REAL del pipeline +// Partners/Aliados (BNI). Stages reales del pipeline 6 (position|name|type, +// verificados por SELECT 2026-06-16): +// 0 Fit identificado (open) · 1 Introducción enviada (open) · 2 Prospecto activo (open) +// 3 Diagnóstico compartido (open) · 4 Seguimiento en marcha (open) +// 5 Ganado (won) · 6 Perdido (lost) +// +// Gaps documentados (auditoría §1, P1, fuera de este P0): +// - "Fit identificado" (0) no lo setea ningún callsite: el deal de partner nace +// al enviarse el opener → "Introducción enviada". +// - "Diagnóstico compartido" (3) no lo alcanza ningún callsite hoy (faltan las +// secuencias Blueprint/Propuesta del journey). +const IA360_PARTNER_STAGE_MAP = { + 'Diagnóstico enviado': 'Introducción enviada', // opener de intro aprobado/enviado + 'Intención detectada': 'Prospecto activo', // respondió / paso 2 de la secuencia + 'Agenda en proceso': 'Seguimiento en marcha', // pidió horarios para llamada con Alek + 'Requiere Alek': 'Seguimiento en marcha', // handoff humano (el pipeline 6 no tiene stage propio) + 'Nutrición': 'Prospecto activo', // "ahora no": sigue prospecto activo (suave) + 'Ganado': 'Ganado', + 'Perdido / no fit': 'Perdido', +}; + +// Devuelve el NOMBRE de pipeline destino para un relationshipContext dado. +// Partner (aliado_socio / referido_bni) → "Partners / Aliados (BNI)"; el resto +// (cliente_activo, beta_amigo, sponsor_ejecutivo, etc. y los flujos sin relación +// como el 100M/Revenue) → el genérico. Nunca lanza: entrada inválida → genérico. +function ia360PipelineForRelationship(relationshipContext) { + return IA360_PARTNER_RELATIONSHIPS.has(String(relationshipContext || '')) + ? IA360_PARTNERS_PIPELINE_NAME + : IA360_DEFAULT_PIPELINE_NAME; +} + +// Traduce el stage solicitado (semántica Revenue) al stage real de un pipeline. +// Solo se traduce para el pipeline Partners; para cualquier otro se devuelve el +// nombre tal cual (el callsite ya pasa el stage real del pipeline genérico). +function ia360ResolveStageName(pipelineName, requestedStageName) { + if (pipelineName !== IA360_PARTNERS_PIPELINE_NAME) return requestedStageName; + return IA360_PARTNER_STAGE_MAP[requestedStageName] || requestedStageName; +} + +module.exports = { + IA360_DEFAULT_PIPELINE_NAME, + IA360_PARTNERS_PIPELINE_NAME, + IA360_PARTNER_RELATIONSHIPS, + IA360_PARTNER_STAGE_MAP, + ia360PipelineForRelationship, + ia360ResolveStageName, +}; diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 61df7e0..bcb9d72 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -10,6 +10,12 @@ const { enqueueSend } = require('../queue/sendQueue'); const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); const { classifyOpenerReply, extractInteractiveReplyId } = require('./ia360OpenerReply'); const { evaluatePaymentStatus } = require('../services/paymentCircuitBreaker'); +const { + IA360_DEFAULT_PIPELINE_NAME, + IA360_PARTNERS_PIPELINE_NAME, + ia360PipelineForRelationship, + ia360ResolveStageName, +} = require('./ia360DealRouting'); const router = Router(); @@ -1161,20 +1167,34 @@ async function recordBlockedOwnerNumberVcard({ record, shared }) { }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); } -async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { +// G8: `pipelineName` enruta el deal. Default = Revenue genérico (no rompe lo +// existente). Los callsites del journey de aliado/referido BNI pasan +// IA360_PARTNERS_PIPELINE_NAME ('Partners / Aliados (BNI)', id 6) — derivado de +// flow.relationshipContext vía ia360PipelineForRelationship. +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '', pipelineName = IA360_DEFAULT_PIPELINE_NAME }) { if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; const { rows: pipeRows } = await pool.query( - `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [pipelineName] ); const pipelineId = pipeRows[0]?.id; if (!pipelineId) return null; + // targetStageName usa la semántica del Revenue pipeline (compartida por los + // handlers persona-first). El pipeline Partners no tiene esos nombres, así que + // se traduce al stage REAL del pipeline 6 (ia360ResolveStageName). El nombre + // LÓGICO original se conserva en requestedStageName para no romper disparadores + // semánticos — p. ej. el handoff n8n de "Requiere Alek", que NO es un stage del + // pipeline Partners pero sí un evento que debe seguir disparándose. + const requestedStageName = targetStageName; + const resolvedStageName = ia360ResolveStageName(pipelineName, requestedStageName); + const { rows: stageRows } = await pool.query( `SELECT id, name, position, stage_type FROM coexistence.pipeline_stages WHERE pipeline_id = $1 AND name = $2 LIMIT 1`, - [pipelineId, targetStageName] + [pipelineId, resolvedStageName] ); const targetStage = stageRows[0]; if (!targetStage) return null; @@ -1222,7 +1242,9 @@ async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] ); // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). - if (targetStage.name === 'Requiere Alek') { + // G8: se evalúa sobre el nombre LÓGICO solicitado (no el stage resuelto), para + // que el handoff siga disparando aunque el pipeline Partners lo mapee a otro stage. + if (requestedStageName === 'Requiere Alek') { emitIa360N8nHandoff({ record, eventType: 'requires_alek', @@ -1256,7 +1278,7 @@ async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. - if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + if (shouldMove && requestedStageName === 'Requiere Alek' && existing.current_stage_name !== targetStage.name) { emitIa360N8nHandoff({ record, eventType: 'requires_alek', @@ -3501,7 +3523,10 @@ async function handleIa360SequenceReply({ record, replyId, contact = null }) { const optionKey = m[2]; const found = findIa360SequenceFlow(sequenceId); if (!found) return false; - const { sequence } = found; + const { flow, sequence } = found; + // G8: aliado_socio / referido_bni → pipeline "Partners / Aliados (BNI)" (id 6); + // el resto de personas (cliente, beta, sponsor, …) sigue en el Revenue genérico. + const dealPipelineName = ia360PipelineForRelationship(flow?.relationshipContext); const option = (sequence.openerOptions?.options || []) .find(o => String(o.id).toLowerCase() === `seq_${sequenceId}:${optionKey}`); if (!option) return false; @@ -3573,6 +3598,7 @@ async function handleIa360SequenceReply({ record, replyId, contact = null }) { targetStageName: 'Requiere Alek', titleSuffix: sequence.label, notes: `Respuesta al opener ${sequence.id}: pidió hablar directo con Alek.`, + pipelineName: dealPipelineName, }).catch(e => console.error('[ia360-seq] deal alek_directo:', e.message)); await notifyOwner('Pidió que le escribas TÚ directo. Deal en "Requiere Alek".'); return true; @@ -3596,6 +3622,7 @@ async function handleIa360SequenceReply({ record, replyId, contact = null }) { targetStageName: 'Nutrición', titleSuffix: sequence.label, notes: `Respuesta al opener ${sequence.id}: ahora no. Pasa a nutrición suave.`, + pipelineName: dealPipelineName, }).catch(e => console.error('[ia360-seq] deal ahora_no:', e.message)); await notifyOwner('Respondió que ahora no; queda en nutrición suave.'); return true; @@ -3624,6 +3651,7 @@ async function handleIa360SequenceReply({ record, replyId, contact = null }) { targetStageName: 'Agenda en proceso', titleSuffix: sequence.label, notes: `Respuesta al opener ${sequence.id}: pidió horarios. Deal a "Agenda en proceso".`, + pipelineName: dealPipelineName, }).catch(e => console.error('[ia360-seq] deal horarios:', e.message)); await notifyOwner('Pidió horarios para una llamada contigo. Deal en "Agenda en proceso".'); return true; @@ -3642,6 +3670,7 @@ async function handleIa360SequenceReply({ record, replyId, contact = null }) { targetStageName: 'Intención detectada', titleSuffix: sequence.label, notes: `Respuesta al opener ${sequence.id}: "${option.title}". Paso 2 de la secuencia enviado.`, + pipelineName: dealPipelineName, }).catch(e => console.error('[ia360-seq] deal step2:', e.message)); await notifyOwner(`Le envié el paso 2 de la secuencia. Next action sugerida: ${sequence.nextAction}`); return true; @@ -3659,6 +3688,7 @@ async function handleIa360SequenceReply({ record, replyId, contact = null }) { targetStageName: 'Intención detectada', titleSuffix: sequence.label, notes: `Respuesta al opener ${sequence.id}: "${option.title}". Acuse enviado; siguiente paso con Alek.`, + pipelineName: dealPipelineName, }).catch(e => console.error('[ia360-seq] deal ack:', e.message)); await notifyOwner(`Next action sugerida: ${sequence.nextAction}`); return true; @@ -4771,12 +4801,14 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } return; } - // Avance del pipeline: el opener salió → "Diagnóstico enviado". + // Avance del pipeline: el opener salió → "Diagnóstico enviado" (genérico) o, para + // aliado/referido BNI, "Introducción enviada" del pipeline Partners (id 6). await syncIa360Deal({ record: targetRecord, targetStageName: 'Diagnóstico enviado', titleSuffix: 'Opener aprobado', notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + pipelineName: ia360PipelineForRelationship(flow?.relationshipContext), }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); await sendIa360DirectText({ @@ -4803,6 +4835,9 @@ async function handleIa360OwnerApproveManual({ record, targetContact }) { targetStageName: 'Requiere Alek', titleSuffix: 'Tomado manual', notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + // G8: si el contacto es aliado/referido BNI, el takeover manual también cae en + // el pipeline Partners (id 6); si no hay relación partner, queda en el genérico. + pipelineName: ia360PipelineForRelationship(contact?.custom_fields?.relationship_context), }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); await sendIa360DirectText({ record, diff --git a/backend/test/ia360AliadoPipelineRouting.test.js b/backend/test/ia360AliadoPipelineRouting.test.js new file mode 100644 index 0000000..5d3013d --- /dev/null +++ b/backend/test/ia360AliadoPipelineRouting.test.js @@ -0,0 +1,123 @@ +// G8 (2026-06-16): los deals de aliado/referido BNI deben caer en su pipeline +// propio "Partners / Aliados (BNI)" (id 6), NO en el Revenue genérico (id 2). +// +// Origen: auditoría 05-Agentes/auditorias/2026-06-15-pipeline-aliado-bni-vs-journey.md +// (gap P0-1). Antes, syncIa360Deal hardcodeaba el pipeline genérico, así que el +// pipeline 6 tenía 0 deals y el journey de partner no se medía. +// +// Este test NO requiere webhook.js (necesitaría express/pool y no hay node_modules): +// requiere el módulo PURO de ruteo (ia360DealRouting) para la lógica, y lee +// webhook.js como STRING para verificar que el cableado real pasa pipelineName. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + IA360_DEFAULT_PIPELINE_NAME, + IA360_PARTNERS_PIPELINE_NAME, + IA360_PARTNER_STAGE_MAP, + ia360PipelineForRelationship, + ia360ResolveStageName, +} = require('../src/routes/ia360DealRouting'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'webhook.js'), 'utf8'); + +// IDs y stages REALES, verificados por SELECT en coexistence (2026-06-16): +// pipelines: 2 = 'IA360 WhatsApp Revenue Pipeline', 6 = 'Partners / Aliados (BNI)'. +// pipeline 6 stages (position|name|type): 0 Fit identificado(open), +// 1 Introducción enviada(open), 2 Prospecto activo(open), +// 3 Diagnóstico compartido(open), 4 Seguimiento en marcha(open), +// 5 Ganado(won), 6 Perdido(lost). +const REAL_PIPELINE_IDS = { + 'IA360 WhatsApp Revenue Pipeline': 2, + 'Partners / Aliados (BNI)': 6, +}; +const REAL_PARTNER_STAGES = new Set([ + 'Fit identificado', 'Introducción enviada', 'Prospecto activo', + 'Diagnóstico compartido', 'Seguimiento en marcha', 'Ganado', 'Perdido', +]); + +// Mimetiza exactamente lo que hace syncIa360Deal: +// SELECT id FROM coexistence.pipelines WHERE name = $1 +// El nombre lo decide el ruteo real (ia360PipelineForRelationship). +function resolvePipelineIdForRelationship(relationshipContext) { + const name = ia360PipelineForRelationship(relationshipContext); + return REAL_PIPELINE_IDS[name] ?? null; +} + +test('deal de ALIADO (aliado_socio) → pipeline "Partners / Aliados (BNI)" id 6', () => { + assert.equal(ia360PipelineForRelationship('aliado_socio'), IA360_PARTNERS_PIPELINE_NAME); + assert.equal(resolvePipelineIdForRelationship('aliado_socio'), 6); +}); + +test('deal de REFERIDO BNI (referido_bni) → pipeline "Partners / Aliados (BNI)" id 6', () => { + assert.equal(ia360PipelineForRelationship('referido_bni'), IA360_PARTNERS_PIPELINE_NAME); + assert.equal(resolvePipelineIdForRelationship('referido_bni'), 6); +}); + +test('deal NORMAL (cliente/beta/sponsor/sin relación) → Revenue genérico id 2', () => { + for (const rel of ['cliente_activo', 'beta_amigo', 'sponsor_ejecutivo', 'director_comercial', '', null, undefined, 'basura']) { + assert.equal(ia360PipelineForRelationship(rel), IA360_DEFAULT_PIPELINE_NAME, `rel=${rel}`); + assert.equal(resolvePipelineIdForRelationship(rel), 2, `rel=${rel}`); + } +}); + +test('todos los stages mapeados de aliado EXISTEN en el pipeline 6 (sin gaps de stage inválido)', () => { + for (const [revenueStage, partnerStage] of Object.entries(IA360_PARTNER_STAGE_MAP)) { + assert.ok( + REAL_PARTNER_STAGES.has(partnerStage), + `El stage "${partnerStage}" (mapeado desde "${revenueStage}") NO existe en el pipeline 6` + ); + } +}); + +test('ia360ResolveStageName traduce stages Revenue→Partners solo para el pipeline 6', () => { + // Pipeline Partners: traduce a stage real. + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Diagnóstico enviado'), 'Introducción enviada'); + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Intención detectada'), 'Prospecto activo'); + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Agenda en proceso'), 'Seguimiento en marcha'); + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Requiere Alek'), 'Seguimiento en marcha'); + // Pipeline genérico: NO traduce (deja el nombre tal cual del Revenue pipeline). + assert.equal(ia360ResolveStageName(IA360_DEFAULT_PIPELINE_NAME, 'Diagnóstico enviado'), 'Diagnóstico enviado'); + assert.equal(ia360ResolveStageName(IA360_DEFAULT_PIPELINE_NAME, 'Requiere Alek'), 'Requiere Alek'); +}); + +// ── Cableado real en webhook.js (anti-regresión por lectura de fuente) ────────── +test('syncIa360Deal acepta pipelineName con default genérico (no rompe lo existente)', () => { + assert.match( + src, + /async function syncIa360Deal\(\{[^}]*pipelineName = IA360_DEFAULT_PIPELINE_NAME[^}]*\}\)/, + 'syncIa360Deal debe aceptar pipelineName con default IA360_DEFAULT_PIPELINE_NAME' + ); + // Aísla el cuerpo de syncIa360Deal (hasta la siguiente declaración de función) + // y verifica que su SELECT de pipeline está PARAMETRIZADO (WHERE name = $1) y ya + // NO hardcodea el nombre. (Otras funciones fuera de scope, p. ej. + // getActiveNonTerminalIa360Deal, sí pueden seguir consultando el genérico.) + const start = src.indexOf('async function syncIa360Deal('); + const after = src.indexOf('\nasync function ', start + 1); + const body = src.slice(start, after === -1 ? undefined : after); + assert.match(body, /SELECT id FROM coexistence\.pipelines WHERE name = \$1/); + assert.doesNotMatch( + body, + /WHERE name = 'IA360 WhatsApp Revenue Pipeline'/, + 'syncIa360Deal ya no debe hardcodear el nombre del pipeline en su SELECT' + ); +}); + +test('los handlers de aliado/referido derivan el pipeline y lo pasan a syncIa360Deal', () => { + // handleIa360SequenceReply deriva dealPipelineName del flow y lo pasa. + assert.match(src, /const dealPipelineName = ia360PipelineForRelationship\(flow\?\.relationshipContext\)/); + const seqPassCount = (src.match(/pipelineName: dealPipelineName,/g) || []).length; + assert.equal(seqPassCount, 5, 'los 5 callsites de handleIa360SequenceReply deben pasar dealPipelineName'); + // handleIa360OwnerApproveSend (opener enviado) deriva del flow. + assert.match(src, /pipelineName: ia360PipelineForRelationship\(flow\?\.relationshipContext\),/); + // handleIa360OwnerApproveManual (takeover) deriva del relationship_context del contacto. + assert.match(src, /pipelineName: ia360PipelineForRelationship\(contact\?\.custom_fields\?\.relationship_context\),/); +}); + +test('el handoff n8n "Requiere Alek" se evalúa sobre el nombre LÓGICO, no el stage resuelto', () => { + // Si se evaluara sobre targetStage.name, el pipeline Partners (que mapea + // "Requiere Alek" → "Seguimiento en marcha") nunca dispararía el handoff. + assert.match(src, /if \(requestedStageName === 'Requiere Alek'\)/); +}); From 1bb6a0effdcf3ad611cb97086d47807448677b4d Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 16 Jun 2026 02:15:52 +0000 Subject: [PATCH 34/39] =?UTF-8?q?feat(ia360):=20G9=20=E2=80=94=20comando?= =?UTF-8?q?=20del=20owner=20para=20teclear=20la=20INTRO=20del=20referido?= =?UTF-8?q?=20(quien=5Fintro)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reanima la intro del referido BNI que estaba muerta (auditoria 2026-06-15, gap #2 / P0-2): quien_intro=NULL en los BNI reales porque solo se capturaba via vCard de un no-owner, y el owner no tenia forma de teclearla. Sin el dato, referido_contexto moria en frio (cold_send_missing_quien_intro) y en caliente (placeholder bloquea). - Nuevo modulo puro src/routes/ia360ReferidoIntro.js (cero deps): parser del comando, sanitize, compact, buildIntroCustomFields, buildReferidoContextoDraft, deteccion de placeholder. webhook.js importa estas funciones en los DOS puntos de consumo. - Comando "intro : " (atajo "referido de "), owner-only, antes del canary; persiste quien_intro y referido_por al ALIADO real (nunca el owner) via mergeContactIa360State. - Frio (gate del template) y caliente (draft) ya consumen custom_fields.quien_intro: con el dato dejan de bloquear y arman la intro real. - sanitizeIa360IntroName delega al modulo (fuente unica vCard+comando). - Test E2E test/ia360QuienIntroCommand.test.js (store en memoria que replica mergeContactIa360State + doble de resolve): simula owner teclea -> verifica persistencia -> verifica frio sin deny y caliente sin placeholder. 6/6, cero egress. - node --check OK. Doc: anexo G9 en la auditoria del vault. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/ia360ReferidoIntro.js | 102 ++++++++ backend/src/routes/webhook.js | 123 +++++++-- backend/test/ia360QuienIntroCommand.test.js | 265 ++++++++++++++++++++ 3 files changed, 475 insertions(+), 15 deletions(-) create mode 100644 backend/src/routes/ia360ReferidoIntro.js create mode 100644 backend/test/ia360QuienIntroCommand.test.js diff --git a/backend/src/routes/ia360ReferidoIntro.js b/backend/src/routes/ia360ReferidoIntro.js new file mode 100644 index 0000000..126f514 --- /dev/null +++ b/backend/src/routes/ia360ReferidoIntro.js @@ -0,0 +1,102 @@ +// G9 (2026-06-16): reanima la INTRO del referido BNI (quien_intro). +// +// Contexto del gap (auditoria 2026-06-15-pipeline-aliado-bni-vs-journey, #2): +// en los BNI reales quien_intro=NULL porque solo se capturaba cuando un NO-owner +// compartia el vCard del referido. El owner (Alek) no tenia forma de teclear +// quien hizo la presentacion, asi que la secuencia referido_contexto se quedaba +// bloqueada: en frio con deny('cold_send_missing_quien_intro') y en caliente con +// el placeholder {{quien_intro}} (copyStatus 'blocked'). +// +// Este modulo es PURO (cero requires, cero I/O): contiene el parser del comando +// del owner, el armado del mensaje de referido (draft caliente + variable fria) +// y el constructor del patch de custom_fields. webhook.js importa estas funciones +// para que el MISMO codigo que corre en produccion sea el que el test E2E ejerce. + +'use strict'; + +const IA360_QUIEN_INTRO_PLACEHOLDER = '{{quien_intro}}'; + +// Comando del owner para teclear quien presento a un referido. Dos formatos +// naturales, ambos owner-only (el dispatch en webhook.js ya filtra por numero): +// - "intro : " (canonico; los dos puntos separan +// sin ambiguedad, asi que sirve aun si el nombre del contacto trae " de ") +// - "referido de " (atajo; el primer " de " separa) +// Devuelve { target, introducer } con ambos trim, o null si no es el comando. +function parseIa360OwnerIntroCommand(body) { + const text = String(body || '').trim(); + let m = text.match(/^intro\s+(.+?)\s*:\s*(.+)$/i); + if (m && m[1].trim() && m[2].trim()) { + return { target: m[1].trim(), introducer: m[2].trim() }; + } + m = text.match(/^referido\s+(.+?)\s+de\s+(.+)$/i); + if (m && m[1].trim() && m[2].trim()) { + return { target: m[1].trim(), introducer: m[2].trim() }; + } + return null; +} + +// Sanitiza un nombre de introductor: quita controles/bidi/zero-width inyectables +// (un push name puede traerlos), borra llaves (no inyectar placeholders), colapsa +// espacios y topa a 60 code points. null si no queda nada con letras. Identico al +// sanitizeIa360IntroName del vCard para que ambos caminos compartan una sola regla. +function sanitizeIntroName(raw) { + const clean = String(raw || '') + .replace(/[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ' ') + .replace(/[{}]/g, '') + .replace(/\s+/g, ' ') + .trim(); + const capped = Array.from(clean).slice(0, 60).join('').trim(); + if (!capped) return null; + if (!/[\p{L}]/u.test(capped)) return null; // sin letras no sirve como nombre + return capped; +} + +// Compacta el quien_intro para el parametro {{2}} del template frio: colapsa +// espacios/saltos (Meta rechaza saltos de linea y 4+ espacios) y topa a max. +// Devuelve null si tras compactar no queda nada (=> cold_send_missing_quien_intro). +function compactQuienIntro(text, max = 60) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + if (!clean) return null; + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +// Patch de custom_fields para el contacto referido a partir de la intro tecleada. +// quien_intro (ya sanitizado) es la senal primaria y siempre gana. referido_por +// solo se setea si el aliado real se resolvio a un numero distinto del owner, del +// bot y del propio contacto: nunca apuntamos el referido al owner. +function buildIntroCustomFields({ + quienIntroName, introducerNumber, ownerNumber, contactNumber, botNumber, nowIso, +}) { + const patch = { + quien_intro: quienIntroName, + intro_capturada_por: 'owner-comando', + intro_capturada_at: nowIso, + }; + const n = introducerNumber ? String(introducerNumber).replace(/\D/g, '') : ''; + if (n && n !== String(ownerNumber || '') && n !== String(botNumber || '') && n !== String(contactNumber || '')) { + patch.referido_por = n; + } + return patch; +} + +// Draft del opener caliente referido_contexto. Con quienIntro arma la intro real; +// sin el, deja el placeholder {{quien_intro}} (que hasUnresolvedIa360Placeholder +// detecta -> copyStatus 'blocked', sin envio). +function buildReferidoContextoDraft({ name, quienIntro }) { + return `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || IA360_QUIEN_INTRO_PLACEHOLDER} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`; +} + +// True si el texto trae el placeholder de quien_intro sin resolver. +function hasUnresolvedQuienIntroPlaceholder(text) { + return String(text || '').includes(IA360_QUIEN_INTRO_PLACEHOLDER); +} + +module.exports = { + IA360_QUIEN_INTRO_PLACEHOLDER, + parseIa360OwnerIntroCommand, + sanitizeIntroName, + compactQuienIntro, + buildIntroCustomFields, + buildReferidoContextoDraft, + hasUnresolvedQuienIntroPlaceholder, +}; diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 61df7e0..90a9c80 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -10,6 +10,13 @@ const { enqueueSend } = require('../queue/sendQueue'); const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); const { classifyOpenerReply, extractInteractiveReplyId } = require('./ia360OpenerReply'); const { evaluatePaymentStatus } = require('../services/paymentCircuitBreaker'); +const { + parseIa360OwnerIntroCommand, + sanitizeIntroName: sanitizeIa360IntroNamePure, + compactQuienIntro: compactQuienIntroPure, + buildIntroCustomFields: buildIa360IntroCustomFields, + buildReferidoContextoDraft: buildIa360ReferidoContextoDraft, +} = require('./ia360ReferidoIntro'); const router = Router(); @@ -1062,18 +1069,11 @@ function inferIa360QaPersonaHint(name) { // el remitente). Se sanitiza antes de persistir: sin caracteres de control ni // saltos de línea, sin llaves de placeholder, espacios colapsados y tope de 60 // caracteres. Devuelve null si no queda nada usable. +// G9: delega en el modulo puro ia360ReferidoIntro para que el vCard y el comando +// del owner "intro : " compartan exactamente la misma regla de +// sanitizado (control/bidi/zero-width fuera, sin llaves, tope 60 code points). function sanitizeIa360IntroName(raw) { - const clean = String(raw || '') - .replace(/[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ' ') - .replace(/[{}]/g, '') - .replace(/\s+/g, ' ') - .trim(); - // Corte por code points (no por unidades UTF-16): un emoji en la frontera de - // los 60 caracteres no deja un surrogate suelto que rompa el jsonb al persistir. - const capped = Array.from(clean).slice(0, 60).join('').trim(); - if (!capped) return null; - if (!/[\p{L}]/u.test(capped)) return null; // sin letras (solo dígitos/símbolos) no sirve como nombre - return capped; + return sanitizeIa360IntroNamePure(raw); } async function upsertIa360SharedContact({ record, shared }) { @@ -2882,7 +2882,7 @@ const IA360_PERSONA_SEQUENCE_FLOWS = { step2: { pregunta: '¿Qué te contó la persona que nos presentó sobre lo que hace Alek, y qué te llamó la atención para aceptar la introducción? Con eso evitamos mandarte algo fuera de lugar.', }, - draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + draft: ({ name, quienIntro }) => buildIa360ReferidoContextoDraft({ name, quienIntro }), // G-COLD: el {{2}} del template es quien_intro; en frío se exige el dato // antes de aprobar (ver handleIa360OwnerApproveSend). metaTemplateName: 'ia360_referido_contexto', @@ -4712,9 +4712,12 @@ async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId } // Meta rechaza parámetros con saltos de línea o 4+ espacios consecutivos. let templateVars = null; if (sequence.id === 'referido_contexto') { - const quienIntro = compactForWhatsApp(contact.custom_fields?.quien_intro || '', 60); + // G9: el quien_intro tecleado por el owner ("intro : ") + // alimenta este mismo {{2}}. compactQuienIntroPure colapsa/topa y devuelve + // null si falta el dato -> seguimos avisando con cold_send_missing_quien_intro. + const quienIntro = compactQuienIntroPure(contact.custom_fields?.quien_intro || '', 60); if (!quienIntro) { - return deny('cold_send_missing_quien_intro', `El template de ${sequence.id} necesita saber quién hizo la introducción y ${name} no tiene quien_intro registrado. Captura ese dato (o elige otra secuencia) y vuelve a intentar. No envié nada.`); + return deny('cold_send_missing_quien_intro', `El template de ${sequence.id} necesita saber quién hizo la introducción y ${name} no tiene quien_intro registrado. Captura ese dato con "intro ${name}: " (o elige otra secuencia) y vuelve a intentar. No envié nada.`); } templateVars = { '2': quienIntro }; } @@ -6039,6 +6042,82 @@ async function handleIa360OwnerVaultSync({ record, query }) { } } +// G9 — Comando del owner: "intro : " (o el atajo +// "referido de "). Reanima la INTRO del referido BNI: persiste +// quien_intro en el contacto referido para que la secuencia referido_contexto +// arme la intro real en lugar de bloquearse (cold_send_missing_quien_intro / +// placeholder caliente). Resuelve el contacto con la misma tolerancia del +// expediente; intenta resolver al aliado real a un numero para referido_por +// (nunca el owner). try/catch total: NUNCA queda mudo. +async function handleIa360OwnerIntroCommand({ record, target, introducer }) { + const ownerText = (label, body) => sendIa360DirectText({ + record, toNumber: IA360_OWNER_NUMBER, label, body, ownerBudget: true, + }); + try { + const quienIntroName = sanitizeIa360IntroNamePure(introducer); + if (!quienIntroName) { + await ownerText('ia360_intro_bad_name', + `No leí un nombre válido de quién presenta en "${introducer}". Usa: intro : .`); + return; + } + const resolved = await resolveIa360MemoryTarget(target); + if (resolved.kind === 'none') { + await ownerText('ia360_intro_target_none', + `No encontré a "${target}" ni como nombre ni como número. Revisa el nombre o usa el número: intro : ${quienIntroName}.`); + return; + } + if (resolved.kind === 'ambiguous') { + const list = resolved.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + await ownerText('ia360_intro_target_ambiguous', + `Hay varios contactos que coinciden con "${target}". ¿A cuál le pongo la intro de ${quienIntroName}?\n${list}\n\nMándame "intro : ${quienIntroName}".`); + return; + } + const contactNumber = normalizePhone(resolved.candidates[0].contact_number); + // Intenta resolver al aliado real a un número para referido_por. Solo cuenta + // un match directo (nombre único o número); si es ambiguo o no existe ficha, + // no tocamos referido_por (quien_intro tecleado ya es la señal primaria). + let introducerNumber = null; + try { + const introResolved = await resolveIa360MemoryTarget(introducer); + if (introResolved.kind === 'name' || introResolved.kind === 'number') { + introducerNumber = normalizePhone(introResolved.candidates[0].contact_number); + } + } catch (e) { + console.error('[ia360-intro] introducer resolve:', e.message); + } + const customFields = buildIa360IntroCustomFields({ + quienIntroName, + introducerNumber, + ownerNumber: IA360_OWNER_NUMBER, + contactNumber, + botNumber: IA360_BOT_WA_NUMBER, + nowIso: new Date().toISOString(), + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields, + }); + const display = resolved.candidates[0].contact_name || contactNumber; + const refLine = customFields.referido_por + ? ` Aliado vinculado: ${customFields.referido_por}.` + : ' (No reconocí un número del aliado; quedó solo el nombre, suficiente para la intro.)'; + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_intro_saved', + body: `Listo. ${display} (${contactNumber}) ahora figura presentado por ${quienIntroName}.${refLine} La secuencia de referido ya arma la intro real sin pedirte el dato.`, + targetContact: contactNumber, + ownerBudget: true, + }); + } catch (err) { + console.error('[ia360-intro] owner command error:', err.message); + await ownerText('ia360_intro_error', + `No pude registrar la intro de "${target}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`).catch(() => {}); + } +} + // Hook al alta de contacto: el primer mensaje real de un número nuevo dispara // UNA sola vez (flag ia360_vault_checked) el auto-match por teléfono y, si no // hay vínculo pero sí candidatos por nombre, la tarjeta al owner. Sin tap del @@ -8966,7 +9045,8 @@ async function handleBrainV2Canary(record) { '- `/sim ` para probar cómo respondería a un contacto.', '- `idea: ` para mandar algo a la bandeja de ideas.', '- `qué sabes de ` para consultar memoria.', - '- `sincroniza a ` para vincular notas del vault.' + '- `sincroniza a ` para vincular notas del vault.', + '- `intro : ` para teclear la intro de un referido.' ].join('\n') }); return; @@ -9213,6 +9293,19 @@ router.post('/webhook/whatsapp', async (req, res) => { continue; // no procesar como mensaje normal } } + // ── G9: comando del owner "intro : " ── + // Reanima la intro del referido BNI: persiste quien_intro para que la + // secuencia referido_contexto deje de bloquearse. Mismo patrón que los + // demás comandos del owner: va ANTES del canary Brain v2 y corta el flujo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const introCmd = parseIa360OwnerIntroCommand(record.message_body); + if (introCmd) { + await handleIa360OwnerIntroCommand({ record, target: introCmd.target, introducer: introCmd.introducer }) + .catch(e => console.error('[ia360-intro] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } // ── CANARY Brain v2 (reversible, allowlist) ────────────────── // Antes de TODO el pipeline del monolito: si el remitente esta en la // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca diff --git a/backend/test/ia360QuienIntroCommand.test.js b/backend/test/ia360QuienIntroCommand.test.js new file mode 100644 index 0000000..d200204 --- /dev/null +++ b/backend/test/ia360QuienIntroCommand.test.js @@ -0,0 +1,265 @@ +// G9 (2026-06-16): E2E del comando del owner para teclear la INTRO del referido +// BNI (quien_intro). Gap origen: auditoria 2026-06-15-pipeline-aliado-bni-vs-journey +// (#2) — quien_intro=NULL en los BNI reales, la secuencia referido_contexto se +// bloquea en frio (cold_send_missing_quien_intro) y en caliente (placeholder +// {{quien_intro}}). Antes NO existia forma de que el owner capturara el dato. +// +// Este test simula el flujo COMPLETO sin deps externas (cero DB/redis/red): +// 1. el owner teclea "intro : " -> parse el comando, +// 2. resuelve el contacto y al aliado (directorio en memoria), +// 3. persiste quien_intro + referido_por (store en memoria que replica EXACTO +// la semantica de mergeContactIa360State: merge shallow de custom_fields), +// 4. verifica que el armado del mensaje de referido (frio Y caliente) USA la +// intro tecleada y NO cae en cold_send_missing_quien_intro / placeholder. +// +// Las funciones puras ejercidas (parser, sanitize, compact, buildIntroCustomFields, +// buildReferidoContextoDraft) son EXACTAMENTE las que webhook.js importa en prod +// (se afirma el cableado abajo), asi que esto prueba el codigo real, no una copia. + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + IA360_QUIEN_INTRO_PLACEHOLDER, + parseIa360OwnerIntroCommand, + sanitizeIntroName, + compactQuienIntro, + buildIntroCustomFields, + buildReferidoContextoDraft, + hasUnresolvedQuienIntroPlaceholder, +} = require('../src/routes/ia360ReferidoIntro'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'webhook.js'), 'utf8'); + +// Numeros reales del monolito (constantes de webhook.js). +const OWNER = '5213322638033'; +const BOT = '5213321594582'; +// Contacto referido en SANDBOX (52199900 + 5 digitos): jamas un real, jamas el owner. +const REFERIDO = '5219990000001'; +// Aliado BNI real (no owner, no bot): es quien presento. +const ALIADO = '5213340001111'; +const ALIADO_NOMBRE = 'Juan Pérez'; + +// ── Doble en memoria del store de contactos ──────────────────────────────── +// Replica la semantica de mergeContactIa360State: INSERT ... ON CONFLICT con +// custom_fields = COALESCE(prev,'{}') || patch (merge shallow, patch gana). +function makeStore(initial = []) { + const rows = new Map(); + for (const r of initial) { + rows.set(`${r.wa_number}|${r.contact_number}`, { + ...r, custom_fields: { ...(r.custom_fields || {}) }, + }); + } + return { + merge({ waNumber, contactNumber, customFields = {} }) { + const k = `${waNumber}|${contactNumber}`; + const prev = rows.get(k) || { + wa_number: waNumber, contact_number: contactNumber, name: null, custom_fields: {}, + }; + prev.custom_fields = { ...(prev.custom_fields || {}), ...customFields }; + rows.set(k, prev); + }, + get(waNumber, contactNumber) { return rows.get(`${waNumber}|${contactNumber}`) || null; }, + }; +} + +// ── Doble de resolveIa360MemoryTarget ────────────────────────────────────── +// Misma logica: >=10 digitos => numero directo; si no, match por nombre +// (normalizado, includes). Directorio chico en memoria. +function makeResolver(directory) { + const norm = (s) => String(s || '') + .toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1').replace(/\s+/g, ' ').trim(); + return (query) => { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const needle = norm(query); + if (!needle) return { kind: 'none', candidates: [] }; + const hits = directory.filter(c => norm(c.contact_name).includes(needle)); + if (!hits.length) return { kind: 'none', candidates: [] }; + if (hits.length > 1) return { kind: 'ambiguous', candidates: hits }; + return { kind: 'name', candidates: hits }; + }; +} + +// ── Replica fiel del control-flow de handleIa360OwnerIntroCommand ────────── +// Usa las MISMAS funciones puras del modulo que webhook.js. Devuelve el ack +// (label + body) y deja el efecto en el store. +function runIntroCommand({ store, resolve, record, target, introducer }) { + const acks = []; + const ownerText = (label, body) => acks.push({ label, body }); + + const quienIntroName = sanitizeIntroName(introducer); + if (!quienIntroName) { + ownerText('ia360_intro_bad_name', `No leí un nombre válido de quién presenta en "${introducer}".`); + return { acks }; + } + const resolved = resolve(target); + if (resolved.kind === 'none') { ownerText('ia360_intro_target_none', 'none'); return { acks }; } + if (resolved.kind === 'ambiguous') { ownerText('ia360_intro_target_ambiguous', 'ambiguous'); return { acks }; } + + const contactNumber = resolved.candidates[0].contact_number; + let introducerNumber = null; + const introResolved = resolve(introducer); + if (introResolved.kind === 'name' || introResolved.kind === 'number') { + introducerNumber = introResolved.candidates[0].contact_number; + } + const customFields = buildIntroCustomFields({ + quienIntroName, + introducerNumber, + ownerNumber: OWNER, + contactNumber, + botNumber: BOT, + nowIso: '2026-06-16T00:00:00.000Z', + }); + store.merge({ waNumber: record.wa_number, contactNumber, customFields }); + ownerText('ia360_intro_saved', `${resolved.candidates[0].contact_name || contactNumber} presentado por ${quienIntroName}`); + return { acks, contactNumber }; +} + +// ── Replica de los dos puntos de consumo en webhook.js ───────────────────── +// FRIO: gate del template referido_contexto (deny cold_send_missing_quien_intro +// si compactQuienIntro devuelve null). Devuelve {deny} o {templateVars}. +function coldReferidoGate(contact) { + const quienIntro = compactQuienIntro(contact?.custom_fields?.quien_intro || '', 60); + if (!quienIntro) return { deny: 'cold_send_missing_quien_intro' }; + return { templateVars: { '2': quienIntro } }; +} +// CALIENTE: draft del opener + deteccion de placeholder (copyStatus 'blocked'). +function hotReferidoDraft(contact, displayName) { + const quienIntro = String(contact?.custom_fields?.quien_intro || '').trim() || null; + const draft = buildReferidoContextoDraft({ name: displayName, quienIntro }); + return { draft, blocked: hasUnresolvedQuienIntroPlaceholder(draft) }; +} + +// ─────────────────────────────────────────────────────────────────────────── + +test('parser: "intro : " y atajo "referido de "', () => { + assert.deepEqual( + parseIa360OwnerIntroCommand('intro Carlos del BNI: Juan Pérez'), + { target: 'Carlos del BNI', introducer: 'Juan Pérez' }, + 'el formato canonico con dos puntos separa aunque el contacto traiga "del"'); + assert.deepEqual( + parseIa360OwnerIntroCommand('referido Carlos de Juan Pérez'), + { target: 'Carlos', introducer: 'Juan Pérez' }); + assert.equal(parseIa360OwnerIntroCommand('hola que tal'), null); + assert.equal(parseIa360OwnerIntroCommand('idea: algo'), null, 'no colisiona con otros comandos'); + assert.equal(parseIa360OwnerIntroCommand('intro Carlos:'), null, 'sin introductor => null'); +}); + +test('E2E: owner teclea la intro -> quien_intro persiste y referido_por apunta al ALIADO (no al owner)', () => { + const store = makeStore([ + // El contacto referido ya existe (capturado por vCard), pero SIN quien_intro. + { wa_number: BOT, contact_number: REFERIDO, name: 'Carlos Sandbox', + custom_fields: { staged: true, referido_por: OWNER /* hoy mal: apunta al owner */ } }, + ]); + const resolve = makeResolver([ + { contact_number: REFERIDO, contact_name: 'Carlos Sandbox' }, + { contact_number: ALIADO, contact_name: ALIADO_NOMBRE }, + ]); + const record = { wa_number: BOT, contact_number: OWNER, message_type: 'text', + message_body: `intro Carlos Sandbox: ${ALIADO_NOMBRE}` }; + + const parsed = parseIa360OwnerIntroCommand(record.message_body); + assert.ok(parsed, 'el comando del owner se reconoce'); + + const { acks, contactNumber } = runIntroCommand({ store, resolve, record, ...parsed }); + + // Ack correcto, sin error. + assert.equal(acks.length, 1); + assert.equal(acks[0].label, 'ia360_intro_saved'); + assert.equal(contactNumber, REFERIDO); + + // quien_intro persistido en el contacto REFERIDO. + const after = store.get(BOT, REFERIDO); + assert.equal(after.custom_fields.quien_intro, ALIADO_NOMBRE, 'quien_intro quedo persistido'); + assert.equal(after.custom_fields.intro_capturada_por, 'owner-comando'); + // referido_por re-apuntado al ALIADO real, ya NO al owner. + assert.equal(after.custom_fields.referido_por, ALIADO, 'referido_por apunta al aliado real'); + assert.notEqual(after.custom_fields.referido_por, OWNER, 'referido_por ya NO es el owner'); +}); + +test('E2E efecto: con quien_intro tecleado, FRIO no deny y CALIENTE no placeholder', () => { + const store = makeStore([ + { wa_number: BOT, contact_number: REFERIDO, name: 'Carlos Sandbox', custom_fields: {} }, + ]); + const resolve = makeResolver([ + { contact_number: REFERIDO, contact_name: 'Carlos Sandbox' }, + { contact_number: ALIADO, contact_name: ALIADO_NOMBRE }, + ]); + const record = { wa_number: BOT, contact_number: OWNER, message_type: 'text' }; + + // ── ANTES de teclear: ambos caminos bloqueados ── + const before = store.get(BOT, REFERIDO); + const coldBefore = coldReferidoGate(before); + const hotBefore = hotReferidoDraft(before, 'Carlos'); + assert.equal(coldBefore.deny, 'cold_send_missing_quien_intro', 'frio: sin dato => deny'); + assert.equal(hotBefore.blocked, true, 'caliente: sin dato => placeholder => blocked'); + assert.ok(hotBefore.draft.includes(IA360_QUIEN_INTRO_PLACEHOLDER), 'el draft trae el placeholder crudo'); + + // ── El owner teclea la intro ── + const parsed = parseIa360OwnerIntroCommand(`intro Carlos Sandbox: ${ALIADO_NOMBRE}`); + runIntroCommand({ store, resolve, record, ...parsed }); + + // ── DESPUES de teclear: ambos caminos desbloqueados, usando la intro real ── + const after = store.get(BOT, REFERIDO); + const coldAfter = coldReferidoGate(after); + const hotAfter = hotReferidoDraft(after, 'Carlos'); + + assert.equal(coldAfter.deny, undefined, 'frio: ya NO deny'); + assert.deepEqual(coldAfter.templateVars, { '2': ALIADO_NOMBRE }, 'frio: el {{2}} usa la intro tecleada'); + + assert.equal(hotAfter.blocked, false, 'caliente: ya NO blocked'); + assert.ok(!hasUnresolvedQuienIntroPlaceholder(hotAfter.draft), 'caliente: sin placeholder'); + assert.ok(hotAfter.draft.includes(`nos presentó ${ALIADO_NOMBRE}`), 'caliente: arma la intro real'); + + // Imprime el contraste de mensajes para el reporte. + console.log('\n [SIN quien_intro] FRIO =>', JSON.stringify(coldBefore)); + console.log(' [SIN quien_intro] CALIENTE =>', hotBefore.draft); + console.log('\n [CON quien_intro] FRIO =>', JSON.stringify(coldAfter)); + console.log(' [CON quien_intro] CALIENTE =>', hotAfter.draft, '\n'); +}); + +test('sanitize: inyeccion de llaves/control se limpia; un nombre vacio no persiste', () => { + assert.equal(sanitizeIntroName('Juan {{2}} Pérez'), 'Juan 2 Pérez', 'quita llaves (no inyectar placeholders)'); + assert.equal(sanitizeIntroName(' '), null); + assert.equal(sanitizeIntroName('123456'), null, 'solo digitos no es nombre'); +}); + +test('referido_por NO se setea si el aliado coincide con owner/bot/contacto', () => { + // Aliado resuelve al OWNER => buildIntroCustomFields no debe poner referido_por. + const cf = buildIntroCustomFields({ + quienIntroName: 'Quien Sea', introducerNumber: OWNER, + ownerNumber: OWNER, contactNumber: REFERIDO, botNumber: BOT, nowIso: 'x', + }); + assert.equal(cf.quien_intro, 'Quien Sea'); + assert.equal(cf.referido_por, undefined, 'nunca apuntamos el referido al owner'); +}); + +test('webhook.js cablea el modulo puro en los DOS puntos de consumo + el comando', () => { + assert.match(src, /require\('\.\/ia360ReferidoIntro'\)/, 'falta el require del modulo'); + // Dispatch del comando del owner. + assert.match(src, /const introCmd = parseIa360OwnerIntroCommand\(record\.message_body\)/); + assert.match(src, /handleIa360OwnerIntroCommand\(\{ record, target: introCmd\.target, introducer: introCmd\.introducer \}\)/); + // El handler persiste via mergeContactIa360State. + const h = src.indexOf('async function handleIa360OwnerIntroCommand'); + assert.notEqual(h, -1, 'falta el handler'); + const hBody = src.slice(h, h + 3000); + assert.match(hBody, /buildIa360IntroCustomFields\(/); + assert.match(hBody, /mergeContactIa360State\(/); + // CALIENTE: el draft usa la funcion del modulo. + assert.match(src, /draft: \(\{ name, quienIntro \}\) => buildIa360ReferidoContextoDraft\(\{ name, quienIntro \}\)/); + // FRIO: el gate usa compactQuienIntroPure y conserva el deny. + const cold = src.indexOf("if (sequence.id === 'referido_contexto')"); + assert.notEqual(cold, -1, 'falta el gate frio'); + const coldBody = src.slice(cold, cold + 600); + assert.match(coldBody, /compactQuienIntroPure\(contact\.custom_fields\?\.quien_intro/); + assert.match(coldBody, /deny\('cold_send_missing_quien_intro'/); +}); From 7bd4533a25c2f57d3cdbc74d57e2b1f83d3c2475 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 16 Jun 2026 02:46:54 +0000 Subject: [PATCH 35/39] docs: runbook operativo IA360 para agentes en el VPS (claude/codex) + sonda template-probe - AGENTS.md/CLAUDE.md: rutas, reglas (fish/bakeado/sandbox/no-egress), patron de trabajo, como correr tests, deploy (build+up, no install.sh), probar envio, consultar DB, estado G5/G6/G8/G9 desplegado y backlog (P1 test vivo, P2 Blueprint/Propuesta, deferred, push 403) - template-probe.js persistente en el repo --- backend/AGENTS.md | 97 +++++++++++++++++++++++++++ backend/CLAUDE.md | 97 +++++++++++++++++++++++++++ backend/template-probe.js | 134 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 backend/AGENTS.md create mode 100644 backend/CLAUDE.md create mode 100644 backend/template-probe.js diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..44cb6e3 --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,97 @@ +# Runbook operativo IA360 / ForgeChat — para agentes en el VPS (claude / codex) + +Este archivo lo cargan claude (CLAUDE.md) y codex (AGENTS.md) al arrancar en +`~/stack/forgechat-poc/backend`. Lee todo antes de trabajar. Estás EN el VPS Linux: +tienes bash, docker, psql y los archivos directos — más fácil que por SSH. + +## Qué es esto +ForgeChat = backend Node del bot de WhatsApp IA360 (TransformIA) de Alek. Atiende +WhatsApp por la **Cloud API oficial de Meta**: openers, secuencias, pipeline de ventas +por persona/journey, aprobación humana del owner. El "cerebro" conversacional vive en +n8n (Brain v2, canary). + +## Rutas y constantes clave +- **Backend (código):** `~/stack/forgechat-poc/backend` — git, rama `main`, remoto `Forgemind-git/ForgeChat`. +- **Deploy (compose):** `~/stack/forgechat-poc` — `docker-compose.yml`, servicio `forgecrm-backend`. +- **Vault / docs:** `~/stack/obsidian/config/MKT` — Obsidian Sync (NO es git aquí), pero hay copia git en `github.com/AlekZen/MKT`. Las auditorías viven en `05-Agentes/auditorias/`. +- **DB:** contenedor `forgecrm-db` (Postgres). `DATABASE_URL` en `backend/.env`. Esquema `coexistence.*` (contacts, deals, pipelines, pipeline_stages, chat_history). +- **Cerebro:** n8n en `n8n.geekstudio.dev`; Brain v2 workflow id `b74vYWxP5YT8dQ2H`. +- **Números:** owner (WhatsApp de Alek) `5213322638033`; bot/emisor `5213321594582`; WABA `1190241942503057` (id 6 pipeline aliados); **sandbox = prefijo `52199900*`**. + +## Reglas de oro (no negociables) +1. **Shell default = fish.** Para scripts y `$(...)`/`&&` usa `bash -lc '...'`. +2. **Código BAKEADO** en el container: editar archivos en disco **NO afecta producción** hasta `docker compose build + up`. Es tu red de seguridad: trabaja tranquilo en disco. +3. **CERO egress a números reales** sin aprobación explícita de Alek. Prueba con sandbox `52199900*` o con el owner `5213322638033`. +4. **No reinicies el backend** (build+up) sin avisar a Alek. +5. Español correcto (acentos, ñ). Sin emojis en scripts (charmap). +6. **No asumas que funciona: testea con datos.** Corre los tests tú mismo y verifica en la DB. + +## Patrón de trabajo (la "ventana dual" de hoy) +1. `git checkout -b gN-nombre` desde `main`. +2. Implementa; saca la lógica a un **módulo puro testeable** (ej. `src/routes/ia360DealRouting.js`, `ia360ReferidoIntro.js`) que `webhook.js` importa. +3. `node --check `. +4. **Test real** en `test/` SIN deps externas (npm no corre: no hay `node_modules`/`pg`). `node --test test/.test.js`. +5. `git commit` en la rama. **Sin deploy todavía.** +6. Audita: corre los tests, revisa `git diff`, confirma que el container no se reinició. +7. Merge a `main` (conflictos de `require` → conserva AMBOS bloques). +8. `node --test` de TODOS los tests en main. +9. Deploy (abajo) y verifica arranque. +10. **Test directo en vivo** (owner/sandbox) y verifica el efecto en la DB. + +## Correr tests +```bash +cd ~/stack/forgechat-poc/backend +node --test test/ia360OpenerButtons.test.js test/paymentCircuitBreaker.test.js \ + test/ia360AliadoPipelineRouting.test.js test/ia360QuienIntroCommand.test.js \ + test/ia360NoSilenceRegression.test.js +``` +Los tests nuevos no usan deps externas. El único fallo global es `access.test.js` +(`Cannot find module 'pg'`) = ambiental, ignóralo. Harness E2E extra (contra +localhost:3011): `~/stack/forgechat-poc/*.sh` (glive-e2e, gbrain-e2e, gcold-e2e, gd-e2e, grag-e2e). + +## Deploy (reinicia el backend ~10s) +```bash +cd ~/stack/forgechat-poc +docker compose build forgecrm-backend +docker compose up -d forgecrm-backend +docker logs forgecrm-backend --tail 8 # OK si dice: [ForgeChat] Backend running on port 3011 +``` +**NO** corras `install.sh` entero (regenera config y toca la DB). Solo build+up del backend. + +## Probar envío de templates al owner +Sonda: `.forgechat-work/template-probe.js` (en el vault) o `/tmp/template-probe.js`. +**Tras cada rebuild el `/tmp` del container se borra** → re-copia: +```bash +docker cp /tmp/template-probe.js forgecrm-backend:/tmp/template-probe.js +cd ~/stack/forgechat-poc/backend; set -a; . ./.env; set +a +docker exec -e ADMIN_EMAIL="$ADMIN_EMAIL" -e ADMIN_PASSWORD="$ADMIN_PASSWORD" \ + -e PROBE_BASE=http://localhost:3011/api -e PROBE_TO=5213322638033 -e PROBE_IDS=42 \ + forgecrm-backend node /tmp/template-probe.js +``` +El status REAL de entrega llega **asíncrono** por webhook. Verifícalo en +`coexistence.chat_history` (status delivered/failed + error_message). El "ERROR" que +imprime la sonda es falso positivo del heurístico: mira el `wamid` y el status en DB. + +## Consultar la DB (esquiva el quoting de fish/docker) +Escribe el SQL a un archivo con **python** (maneja comillas limpio) y: +```bash +DBURL=$(grep -E '^DATABASE_URL=' ~/stack/forgechat-poc/backend/.env | cut -d= -f2- | tr -d '"') +docker exec -i forgecrm-db psql "$DBURL" -t -A -F'|' < /tmp/q.sql +``` + +## Estado del trabajo — cierre 2026-06-15 (DESPLEGADO en producción, main) +- **G6** botones de opener ("Sí, cuéntame"→Revenue OS paso2 / "Ahora no"→cierre). +- **G5** circuit breaker de pago: detecta status `failed` 131042 en el webhook, alerta al owner (anti-spam), `skipRetry` en sendQueue; gate de pausa NO activado (deadlock). +- **G8** ruteo: deals con `relationshipContext` `aliado_socio`/`referido_bni` → pipeline "Partners / Aliados (BNI)" (id 6), no al genérico. `syncIa360Deal(pipelineName=...)`, módulo `ia360DealRouting.js`. +- **G9** comando owner `intro : ` (atajo `referido de `) puebla `quien_intro` → desbloquea la secuencia de referido. Módulo `ia360ReferidoIntro.js`. + +## Pendientes (backlog) +- **P1 (test vivo):** Alek teclea `intro 5210000002102: ` desde su WhatsApp → verificar `quien_intro` en `coexistence.contacts`. +- **P2 (journey aliado):** faltan etapas **Blueprint** y **Propuesta** (templates nuevos en Meta + secuencias). Stages del pipeline 6 `Fit identificado` (pos 0) y `Diagnóstico compartido` (pos 3) no los setea ningún callsite. +- **DEFERRED (optimización, NO ahora):** latencia del cerebro Brain v2 (nodo responder gpt-5 ~27s) se corta por timeout 30s de `callIa360Agent` (webhook.js ~L2669). Opción: async sin timeout. Nota: Hermes edita mensajes porque usa **Baileys/WhatsApp-Web** (`/opt/hermes-agent/.../whatsapp-bridge/bridge.js`), no la Cloud API; la Cloud API oficial NO edita salientes. +- **BLOQUEO push:** `git push` del backend falla **403** (usuario `AlekZen` sin permiso a `Forgemind-git/ForgeChat`). Los commits están en `main` local del VPS; resolver permiso o pushear con la cuenta dueña. + +## Docs de referencia (en el vault) +- `05-Agentes/auditorias/2026-06-15 - Auditoria coherencia y UX pipelines WhatsApp y Email.md` +- `05-Agentes/auditorias/2026-06-15-diagnostico-no-delivery.md` +- `05-Agentes/auditorias/2026-06-15-pipeline-aliado-bni-vs-journey.md` diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000..44cb6e3 --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1,97 @@ +# Runbook operativo IA360 / ForgeChat — para agentes en el VPS (claude / codex) + +Este archivo lo cargan claude (CLAUDE.md) y codex (AGENTS.md) al arrancar en +`~/stack/forgechat-poc/backend`. Lee todo antes de trabajar. Estás EN el VPS Linux: +tienes bash, docker, psql y los archivos directos — más fácil que por SSH. + +## Qué es esto +ForgeChat = backend Node del bot de WhatsApp IA360 (TransformIA) de Alek. Atiende +WhatsApp por la **Cloud API oficial de Meta**: openers, secuencias, pipeline de ventas +por persona/journey, aprobación humana del owner. El "cerebro" conversacional vive en +n8n (Brain v2, canary). + +## Rutas y constantes clave +- **Backend (código):** `~/stack/forgechat-poc/backend` — git, rama `main`, remoto `Forgemind-git/ForgeChat`. +- **Deploy (compose):** `~/stack/forgechat-poc` — `docker-compose.yml`, servicio `forgecrm-backend`. +- **Vault / docs:** `~/stack/obsidian/config/MKT` — Obsidian Sync (NO es git aquí), pero hay copia git en `github.com/AlekZen/MKT`. Las auditorías viven en `05-Agentes/auditorias/`. +- **DB:** contenedor `forgecrm-db` (Postgres). `DATABASE_URL` en `backend/.env`. Esquema `coexistence.*` (contacts, deals, pipelines, pipeline_stages, chat_history). +- **Cerebro:** n8n en `n8n.geekstudio.dev`; Brain v2 workflow id `b74vYWxP5YT8dQ2H`. +- **Números:** owner (WhatsApp de Alek) `5213322638033`; bot/emisor `5213321594582`; WABA `1190241942503057` (id 6 pipeline aliados); **sandbox = prefijo `52199900*`**. + +## Reglas de oro (no negociables) +1. **Shell default = fish.** Para scripts y `$(...)`/`&&` usa `bash -lc '...'`. +2. **Código BAKEADO** en el container: editar archivos en disco **NO afecta producción** hasta `docker compose build + up`. Es tu red de seguridad: trabaja tranquilo en disco. +3. **CERO egress a números reales** sin aprobación explícita de Alek. Prueba con sandbox `52199900*` o con el owner `5213322638033`. +4. **No reinicies el backend** (build+up) sin avisar a Alek. +5. Español correcto (acentos, ñ). Sin emojis en scripts (charmap). +6. **No asumas que funciona: testea con datos.** Corre los tests tú mismo y verifica en la DB. + +## Patrón de trabajo (la "ventana dual" de hoy) +1. `git checkout -b gN-nombre` desde `main`. +2. Implementa; saca la lógica a un **módulo puro testeable** (ej. `src/routes/ia360DealRouting.js`, `ia360ReferidoIntro.js`) que `webhook.js` importa. +3. `node --check `. +4. **Test real** en `test/` SIN deps externas (npm no corre: no hay `node_modules`/`pg`). `node --test test/.test.js`. +5. `git commit` en la rama. **Sin deploy todavía.** +6. Audita: corre los tests, revisa `git diff`, confirma que el container no se reinició. +7. Merge a `main` (conflictos de `require` → conserva AMBOS bloques). +8. `node --test` de TODOS los tests en main. +9. Deploy (abajo) y verifica arranque. +10. **Test directo en vivo** (owner/sandbox) y verifica el efecto en la DB. + +## Correr tests +```bash +cd ~/stack/forgechat-poc/backend +node --test test/ia360OpenerButtons.test.js test/paymentCircuitBreaker.test.js \ + test/ia360AliadoPipelineRouting.test.js test/ia360QuienIntroCommand.test.js \ + test/ia360NoSilenceRegression.test.js +``` +Los tests nuevos no usan deps externas. El único fallo global es `access.test.js` +(`Cannot find module 'pg'`) = ambiental, ignóralo. Harness E2E extra (contra +localhost:3011): `~/stack/forgechat-poc/*.sh` (glive-e2e, gbrain-e2e, gcold-e2e, gd-e2e, grag-e2e). + +## Deploy (reinicia el backend ~10s) +```bash +cd ~/stack/forgechat-poc +docker compose build forgecrm-backend +docker compose up -d forgecrm-backend +docker logs forgecrm-backend --tail 8 # OK si dice: [ForgeChat] Backend running on port 3011 +``` +**NO** corras `install.sh` entero (regenera config y toca la DB). Solo build+up del backend. + +## Probar envío de templates al owner +Sonda: `.forgechat-work/template-probe.js` (en el vault) o `/tmp/template-probe.js`. +**Tras cada rebuild el `/tmp` del container se borra** → re-copia: +```bash +docker cp /tmp/template-probe.js forgecrm-backend:/tmp/template-probe.js +cd ~/stack/forgechat-poc/backend; set -a; . ./.env; set +a +docker exec -e ADMIN_EMAIL="$ADMIN_EMAIL" -e ADMIN_PASSWORD="$ADMIN_PASSWORD" \ + -e PROBE_BASE=http://localhost:3011/api -e PROBE_TO=5213322638033 -e PROBE_IDS=42 \ + forgecrm-backend node /tmp/template-probe.js +``` +El status REAL de entrega llega **asíncrono** por webhook. Verifícalo en +`coexistence.chat_history` (status delivered/failed + error_message). El "ERROR" que +imprime la sonda es falso positivo del heurístico: mira el `wamid` y el status en DB. + +## Consultar la DB (esquiva el quoting de fish/docker) +Escribe el SQL a un archivo con **python** (maneja comillas limpio) y: +```bash +DBURL=$(grep -E '^DATABASE_URL=' ~/stack/forgechat-poc/backend/.env | cut -d= -f2- | tr -d '"') +docker exec -i forgecrm-db psql "$DBURL" -t -A -F'|' < /tmp/q.sql +``` + +## Estado del trabajo — cierre 2026-06-15 (DESPLEGADO en producción, main) +- **G6** botones de opener ("Sí, cuéntame"→Revenue OS paso2 / "Ahora no"→cierre). +- **G5** circuit breaker de pago: detecta status `failed` 131042 en el webhook, alerta al owner (anti-spam), `skipRetry` en sendQueue; gate de pausa NO activado (deadlock). +- **G8** ruteo: deals con `relationshipContext` `aliado_socio`/`referido_bni` → pipeline "Partners / Aliados (BNI)" (id 6), no al genérico. `syncIa360Deal(pipelineName=...)`, módulo `ia360DealRouting.js`. +- **G9** comando owner `intro : ` (atajo `referido de `) puebla `quien_intro` → desbloquea la secuencia de referido. Módulo `ia360ReferidoIntro.js`. + +## Pendientes (backlog) +- **P1 (test vivo):** Alek teclea `intro 5210000002102: ` desde su WhatsApp → verificar `quien_intro` en `coexistence.contacts`. +- **P2 (journey aliado):** faltan etapas **Blueprint** y **Propuesta** (templates nuevos en Meta + secuencias). Stages del pipeline 6 `Fit identificado` (pos 0) y `Diagnóstico compartido` (pos 3) no los setea ningún callsite. +- **DEFERRED (optimización, NO ahora):** latencia del cerebro Brain v2 (nodo responder gpt-5 ~27s) se corta por timeout 30s de `callIa360Agent` (webhook.js ~L2669). Opción: async sin timeout. Nota: Hermes edita mensajes porque usa **Baileys/WhatsApp-Web** (`/opt/hermes-agent/.../whatsapp-bridge/bridge.js`), no la Cloud API; la Cloud API oficial NO edita salientes. +- **BLOQUEO push:** `git push` del backend falla **403** (usuario `AlekZen` sin permiso a `Forgemind-git/ForgeChat`). Los commits están en `main` local del VPS; resolver permiso o pushear con la cuenta dueña. + +## Docs de referencia (en el vault) +- `05-Agentes/auditorias/2026-06-15 - Auditoria coherencia y UX pipelines WhatsApp y Email.md` +- `05-Agentes/auditorias/2026-06-15-diagnostico-no-delivery.md` +- `05-Agentes/auditorias/2026-06-15-pipeline-aliado-bni-vs-journey.md` diff --git a/backend/template-probe.js b/backend/template-probe.js new file mode 100644 index 0000000..43164fd --- /dev/null +++ b/backend/template-probe.js @@ -0,0 +1,134 @@ +// template-probe.js — Sondeo real de envío de templates al número del owner. +// +// Para qué: enviar CADA template aprobada al WhatsApp del owner a través del +// path HTTP real de ForgeChat (corre el validador + sendQueue + Meta), y +// documentar la respuesta REAL de Meta por template (éxito o código de error, +// p.ej. 131042). Sirve para verificar si el bloqueo de billing afecta o no +// las operaciones actuales. +// +// DÓNDE se corre: en el VPS (donde el backend escucha en localhost:3011 y el +// token de Meta vive en el .env). NO funciona desde una máquina sin el backend. +// +// Uso (en el VPS, con las credenciales admin del backend en el entorno): +// ADMIN_EMAIL=... ADMIN_PASSWORD=... node template-probe.js +// Opcionales: +// PROBE_TO=5213322638033 # número destino (default: owner) +// PROBE_IDS=44,45,46 # solo estos IDs (default: todas las del API) +// PROBE_ONLY_APPROVED=1 # solo status APPROVED (default: 1) +// PROBE_DELAY_MS=1500 # pausa entre envíos (default: 1500) +// PROBE_OUT=/ruta/salida.md # archivo markdown de resultados +// +// El script NUNCA inventa: imprime y guarda tal cual lo que devuelve el backend/Meta. + +const BASE = process.env.PROBE_BASE || 'http://localhost:3011/api'; +const TO = process.env.PROBE_TO || '5213322638033'; // owner real (Alek) +const ONLY_APPROVED = process.env.PROBE_ONLY_APPROVED !== '0'; +const DELAY_MS = Number(process.env.PROBE_DELAY_MS || 1500); +const ONLY_IDS = (process.env.PROBE_IDS || '') + .split(',').map(s => s.trim()).filter(Boolean).map(Number); +const OUT = process.env.PROBE_OUT || + `template-probe-results-${new Date().toISOString().slice(0, 10)}.md`; + +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + +function pickSampleValues(tpl) { + // Cuenta los placeholders {{n}} en el cuerpo para mandar muestras válidas. + const body = (tpl.components || tpl.body || []).map ? + JSON.stringify(tpl.components || tpl.body) : JSON.stringify(tpl); + const text = typeof tpl.bodyText === 'string' ? tpl.bodyText : body; + const nums = new Set(); + const re = /\{\{\s*(\d+)\s*\}\}/g; let m; + while ((m = re.exec(text)) !== null) nums.add(Number(m[1])); + const sample = {}; + for (const n of nums) sample[String(n)] = n === 1 ? 'Alek' : `muestra${n}`; + if (Object.keys(sample).length === 0) sample['1'] = 'Alek'; + return sample; +} + +(async () => { + const email = process.env.ADMIN_EMAIL; + const password = process.env.ADMIN_PASSWORD; + if (!email || !password) { + console.error('Falta ADMIN_EMAIL / ADMIN_PASSWORD en el entorno.'); + process.exit(1); + } + + // 1) Login + const login = await fetch(`${BASE}/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const cookie = (login.headers.get('set-cookie') || '').split(';')[0]; + console.log('LOGIN status=' + login.status); + if (login.status !== 200) { console.log(await login.text()); process.exit(1); } + + // 2) Listar templates + let templates = []; + try { + const tr = await fetch(`${BASE}/templates`, { headers: { cookie } }); + const data = await tr.json(); + templates = Array.isArray(data) ? data : (data.templates || data.items || data.data || []); + } catch (e) { + console.error('No pude listar /templates:', e.message); + } + if (!templates.length) { + console.error('Sin templates desde el API. Usa PROBE_IDS=44,45,... para forzar IDs.'); + if (!ONLY_IDS.length) process.exit(1); + templates = ONLY_IDS.map(id => ({ id, name: `(id ${id})`, status: 'UNKNOWN' })); + } + + let pool = templates; + if (ONLY_IDS.length) pool = pool.filter(t => ONLY_IDS.includes(Number(t.id))); + if (ONLY_APPROVED) pool = pool.filter(t => !t.status || String(t.status).toUpperCase() === 'APPROVED'); + + console.log(`Probando ${pool.length} templates hacia ${TO}\n`); + + const results = []; + for (const tpl of pool) { + const sampleValues = pickSampleValues(tpl); + let status = 0, body = ''; + try { + const ts = await fetch(`${BASE}/templates/${tpl.id}/test-send`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ to: TO, sampleValues }), + }); + status = ts.status; + body = await ts.text(); + } catch (e) { body = 'FETCH_ERR ' + e.message; } + + // Intenta extraer código de error de Meta del body + let metaCode = ''; + const cm = body.match(/"code"\s*:\s*(\d+)/) || body.match(/\b(13\d{4})\b/); + if (cm) metaCode = cm[1]; + const ok = status === 200 && !/error|fail|131\d{3}|132\d{3}/i.test(body); + + const row = { + id: tpl.id, name: tpl.name || '', cat: tpl.category || '', + meta: tpl.metaTemplateName || '', http: status, + verdict: ok ? 'OK' : 'ERROR', metaCode, + bodySnippet: body.slice(0, 240).replace(/\n/g, ' '), + }; + results.push(row); + console.log(`#${row.id} ${row.name} -> HTTP ${row.http} ${row.verdict}${metaCode ? ' meta=' + metaCode : ''}`); + await sleep(DELAY_MS); + } + + // 3) Escribir markdown + const okN = results.filter(r => r.verdict === 'OK').length; + const errN = results.length - okN; + const lines = []; + lines.push(`# Sondeo de templates -> ${TO} (${new Date().toISOString()})`); + lines.push(''); + lines.push(`Total: ${results.length} · OK: ${okN} · ERROR: ${errN}`); + lines.push(''); + lines.push('| ID | Nombre | Cat | metaTemplateName | HTTP | Veredicto | Meta code | Respuesta (recorte) |'); + lines.push('|----|--------|-----|------------------|------|-----------|-----------|---------------------|'); + for (const r of results) { + lines.push(`| ${r.id} | ${r.name} | ${r.cat} | ${r.meta} | ${r.http} | ${r.verdict} | ${r.metaCode} | ${r.bodySnippet.replace(/\|/g, '/')} |`); + } + require('fs').writeFileSync(OUT, lines.join('\n'), 'utf8'); + console.log(`\nResultados: OK=${okN} ERROR=${errN}`); + console.log('Markdown:', OUT); +})().catch(e => { console.error('ERR', e.message); process.exit(1); }); From 73e111cc458e83d4ca2778df75db675855f611cb Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 16 Jun 2026 03:00:29 +0000 Subject: [PATCH 36/39] docs: runbook + prompt orquestador tmux para agentes del VPS --- AGENTS.md | 2 + CLAUDE.md | 2 + ORQUESTADOR-tmux-prompt.md | 84 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 ORQUESTADOR-tmux-prompt.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..29272e1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,2 @@ +# ForgeChat — el codigo y el runbook estan en ./backend +Lee backend/AGENTS.md (runbook operativo IA360) antes de trabajar. El backend se despliega desde aqui: docker compose build forgecrm-backend && docker compose up -d forgecrm-backend diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..29272e1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +# ForgeChat — el codigo y el runbook estan en ./backend +Lee backend/AGENTS.md (runbook operativo IA360) antes de trabajar. El backend se despliega desde aqui: docker compose build forgecrm-backend && docker compose up -d forgecrm-backend diff --git a/ORQUESTADOR-tmux-prompt.md b/ORQUESTADOR-tmux-prompt.md new file mode 100644 index 0000000..de60673 --- /dev/null +++ b/ORQUESTADOR-tmux-prompt.md @@ -0,0 +1,84 @@ +# Prompt — Orquestador tmux (automator) para IA360 / ForgeChat + +Pega esto a un claude o codex corriendo EN el VPS (`~/stack/forgechat-poc/backend`). +Es el cerebro orquestador; los ejecutores son agentes headless que tú lanzas en tmux. + +--- + +Eres el **ORQUESTADOR**. NO implementas tú: descompones el objetivo en *goals* verificables, +lanzas un **ejecutor headless por goal en su propia sesión tmux**, y **auditas en el disco real** +lo que cada ejecutor reporta como hecho (no confías en su transcript). Tu superpoder es tmux: +úsalo como automator para correr varios ejecutores en paralelo y vigilarlos sin bloquearte. + +## Entorno (ya estás dentro del VPS Linux) +- Shell por defecto = **fish**. Para todo script/`$()`/`&&` usa `bash -lc '...'`. +- Backend (código): `~/stack/forgechat-poc/backend` (git, rama `main`, remoto Forgemind-git/ForgeChat — push da 403, ignóralo, commitea local). +- Deploy: `~/stack/forgechat-poc` → `docker compose build forgecrm-backend && docker compose up -d forgecrm-backend`. +- Código **BAKEADO**: editar disco NO afecta producción hasta build+up. Es tu red de seguridad. +- DB: contenedor `forgecrm-db` (Postgres), `DATABASE_URL` en `backend/.env`, esquema `coexistence.*`. +- Cerebro: n8n (`n8n.geekstudio.dev`), Brain v2 workflow `b74vYWxP5YT8dQ2H`. +- Owner WhatsApp `5213322638033`; bot `5213321594582`; **sandbox = prefijo `52199900*`**. +- **Runbook operativo completo: `~/stack/forgechat-poc/backend/AGENTS.md`. Léelo antes de nada.** + +## Reglas duras (rómpelas y vales verga) +1. **NUNCA asumas que funciona. Testea con DATOS**: corre los tests tú mismo y verifica el efecto en la DB / en el WhatsApp del owner. Reportar "debería funcionar" está prohibido. +2. **CERO egress a números reales** sin OK explícito de Alek. Pruebas solo con sandbox `52199900*` o el owner. +3. **No reinicies el backend** (build+up) sin avisar a Alek. El deploy es un gate humano. +4. El orquestador **audita en disco**, no en el transcript del ejecutor: corre el check, lee el diff, confirma que el container no se reinició. +5. Español correcto (acentos, ñ). Sin emojis en scripts. +6. Un goal nunca es vago. Siempre: **end-state medible + check concreto + constraints + cota de turnos ("para tras N turnos")**. + +## Mecánica tmux (tu automator) +Lanzar un ejecutor headless para un goal (read-only o con cambios en rama): +```bash +# 1) escribe el goal a archivo (evita el infierno de comillas) +cat > /tmp/-goal.txt <<'GOAL' +/goal +GOAL +# 2) launcher en bash (el pane de tmux es fish; fuerza bash) +cat > /tmp/run-.sh <<'RUN' +#!/bin/bash +cd ~/stack/forgechat-poc/backend +claude --dangerously-skip-permissions -p "$(cat /tmp/-goal.txt)" 2>&1 | tee ~/.log +echo "DONE_" +RUN +chmod +x /tmp/run-.sh +tmux new-session -d -s "bash /tmp/run-.sh" +``` +Monitorear sin bloquearte (sondea, no asumas): +```bash +tmux capture-pane -t -p | tail -20 # ver progreso +grep -c "DONE_" ~/.log # terminó? +pgrep -f "claude --dangerously" | head # sigue vivo? +``` +Recoger y limpiar: cuando todos terminen, `tmux kill-session -t `. + +**Paralelización:** lanza en sesiones tmux distintas los goals **independientes** (no tocan el mismo +archivo ni dependen entre sí) y vigílalos a la vez. Los goals **dependientes** (mismo archivo, o uno +necesita el output del otro) van en secuencia. Si dos goals tocan `webhook.js`, ejecútalos en serie +o en ramas separadas y resuelve el merge tú (conflictos de `require` → conserva ambos bloques). + +## Ciclo de cada goal +1. **Descompón**: end-state, check, constraints, cota. Saca la lógica a un módulo puro testeable. +2. **Lanza** el ejecutor en tmux (arriba). El ejecutor: rama `gN-nombre` desde `main`, implementa, `node --check`, escribe test REAL en `test/` (sin deps externas — npm no corre), corre el test, commitea en la rama. **Sin deploy.** +3. **Audita en disco** (tú, el orquestador): `node --check`, `node --test test/.test.js` corrido por ti, `git diff main...gN --stat`, confirma `docker ps` (container no reiniciado), revisa la calidad (no solo que pase tests: que los símbolos runtime existan, que la lógica sea coherente). +4. **Veredicto**: APROBADO → merge a main; RECHAZADO → re-lanza el ejecutor con feedback concreto del disco (self-healing). +5. **Integra**: merge a main, corre TODOS los tests, **deploy** (build+up, avisa a Alek), verifica arranque (`[ForgeChat] Backend running on port 3011`). +6. **Prueba en vivo con datos**: envía/teclea desde el owner o inyecta sandbox, y **verifica el efecto en la DB**. Si no lo ves en datos, no está hecho. + +## Estado actual y objetivo +Desplegado en `main`: G6 (botones opener), G5 (circuit breaker pago 131042), G8 (deals aliado→pipeline 6), +G9 (comando `intro : `). Pendientes (ver AGENTS.md §backlog): +- **P1**: test vivo de `quien_intro` (owner teclea `intro 5210000002102: ` → verificar en `coexistence.contacts`). +- **P2**: etapas **Blueprint** y **Propuesta** del journey de aliado BNI (templates nuevos en Meta + secuencias; stages pipeline 6 `Fit identificado` pos0 y `Diagnóstico compartido` pos3 sin callsite). +- **DEFERRED**: latencia del cerebro Brain v2 (timeout 30s lo corta) — async sin timeout. +- **Push 403** del backend a GitHub (permiso de AlekZen en Forgemind-git/ForgeChat). + +Objetivo de negocio que manda: **que el pipeline VENDA con lógica** y mande templates de forma lógica +(para el owner y para contactos/clientes), respetando los customer journeys definidos +(`05-Agentes/auditorias/2026-06-15-pipeline-aliado-bni-vs-journey.md` y `03-Recursos/Customer journeys - TransformIA.md`). + +## Arranque +1. Lee `AGENTS.md` y el doc del journey de aliado. +2. Propón a Alek el plan de goals (tabla: goal · end-state · check · paralelizable sí/no) y **espera su OK**. +3. Ejecuta el ciclo, auditando en disco cada `done`, probando en vivo con datos. Reporta corto y honesto: qué quedó probado con datos y qué falta. From adf7c5ff78f55abf4a3da6038f820ede4e62f54e Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 16 Jun 2026 03:18:05 +0000 Subject: [PATCH 37/39] =?UTF-8?q?feat(ia360):=20G10=20P2=20=E2=80=94=20sta?= =?UTF-8?q?ges=20Fit=20identificado/Diagnostico=20compartido=20+=20Bluepri?= =?UTF-8?q?nt/Propuesta=20stub=20fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/ia360DealRouting.js | 64 +++++++++++ backend/src/routes/webhook.js | 19 ++++ .../test/ia360PartnerBlueprintStages.test.js | 106 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 backend/test/ia360PartnerBlueprintStages.test.js diff --git a/backend/src/routes/ia360DealRouting.js b/backend/src/routes/ia360DealRouting.js index f117f43..a241213 100644 --- a/backend/src/routes/ia360DealRouting.js +++ b/backend/src/routes/ia360DealRouting.js @@ -35,11 +35,17 @@ const IA360_PARTNER_RELATIONSHIPS = new Set(['aliado_socio', 'referido_bni']); // - "Diagnóstico compartido" (3) no lo alcanza ningún callsite hoy (faltan las // secuencias Blueprint/Propuesta del journey). const IA360_PARTNER_STAGE_MAP = { + 'Fit identificado': 'Fit identificado', // G10: pre-envío, partner identificado como fit (pos 0) 'Diagnóstico enviado': 'Introducción enviada', // opener de intro aprobado/enviado 'Intención detectada': 'Prospecto activo', // respondió / paso 2 de la secuencia 'Agenda en proceso': 'Seguimiento en marcha', // pidió horarios para llamada con Alek 'Requiere Alek': 'Seguimiento en marcha', // handoff humano (el pipeline 6 no tiene stage propio) 'Nutrición': 'Prospecto activo', // "ahora no": sigue prospecto activo (suave) + // G10 (P2): el journey aliado alcanza "Diagnóstico compartido" (pos 3) por tres + // vías semánticas; las tres caen en el MISMO stage real del pipeline 6. + 'Diagnóstico compartido': 'Diagnóstico compartido', // stage real directo + 'Blueprint compartido': 'Diagnóstico compartido', // secuencia Blueprint entregada + 'Propuesta enviada': 'Diagnóstico compartido', // secuencia Propuesta entregada 'Ganado': 'Ganado', 'Perdido / no fit': 'Perdido', }; @@ -62,6 +68,60 @@ function ia360ResolveStageName(pipelineName, requestedStageName) { return IA360_PARTNER_STAGE_MAP[requestedStageName] || requestedStageName; } +// ============================================================================ +// G10 (P2): secuencias Blueprint / Propuesta del journey aliado — STUB INERTE +// detrás de un flag que FALLA CERRADO. +// +// Hoy NO existen los templates de Meta para estas dos etapas (es el gap P2: hay +// que crearlos y aprobarlos). Hasta que existan, NO debe poder salir nada: ni +// ofrecerse en menús/readouts (flag OFF) ni enviarse sin template aprobado +// (predicado fail-closed). Este módulo solo declara el catálogo y los predicados +// PUROS; la lógica de envío real (cuando exista) vive en webhook.js y DEBE pasar +// por los gates de cold-send existentes (outside_window_template_not_approved / +// cold_template_status_check_failed). Sin template aprobado → no enviable. +// +// DEFAULT OFF: solo se activa con IA360_PARTNER_BLUEPRINT=on en el entorno. +const IA360_PARTNER_BLUEPRINT_ENABLED = process.env.IA360_PARTNER_BLUEPRINT === 'on'; + +// Catálogo INERTE: describe las dos secuencias nuevas y a qué stage real del +// pipeline 6 llevan ("Diagnóstico compartido", pos 3). metaTemplateName apunta al +// template de Meta que TODAVÍA no existe — por eso el predicado falla cerrado. +const IA360_PARTNER_BLUEPRINT_SEQUENCES = [ + { + id: 'partner_blueprint', + label: 'Blueprint compartido', + metaTemplateName: 'ia360_partner_blueprint', + stage: 'Diagnóstico compartido', + }, + { + id: 'partner_propuesta', + label: 'Propuesta enviada', + metaTemplateName: 'ia360_partner_propuesta', + stage: 'Diagnóstico compartido', + }, +]; + +// Devuelve las secuencias OFRECIBLES. Con el flag OFF → [] (no se ofrecen en +// ningún menú/readout). El estado del flag es inyectable para poder testear +// ambos lados sin recargar el proceso (el const se evalúa en load-time). +function ia360PartnerBlueprintSequences(enabled = IA360_PARTNER_BLUEPRINT_ENABLED) { + return enabled ? IA360_PARTNER_BLUEPRINT_SEQUENCES.slice() : []; +} + +// Predicado fail-closed: una secuencia Blueprint/Propuesta SOLO es enviable si +// (a) el flag está ON, y +// (b) su template de Meta está aprobado/presente (approvedTemplateName coincide +// con el metaTemplateName de la secuencia). +// Falta el template (''/null/no coincide) → FALSE. Flag OFF → FALSE. Este es el +// corazón del fail-closed: sin template aprobado NO se envía. +function ia360PartnerBlueprintSendable(seq, approvedTemplateName, enabled = IA360_PARTNER_BLUEPRINT_ENABLED) { + if (!enabled) return false; + if (!seq || !seq.metaTemplateName) return false; + const approved = String(approvedTemplateName || '').trim(); + if (!approved) return false; + return approved === seq.metaTemplateName; +} + module.exports = { IA360_DEFAULT_PIPELINE_NAME, IA360_PARTNERS_PIPELINE_NAME, @@ -69,4 +129,8 @@ module.exports = { IA360_PARTNER_STAGE_MAP, ia360PipelineForRelationship, ia360ResolveStageName, + IA360_PARTNER_BLUEPRINT_ENABLED, + IA360_PARTNER_BLUEPRINT_SEQUENCES, + ia360PartnerBlueprintSequences, + ia360PartnerBlueprintSendable, }; diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index fc800aa..7351871 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -13,6 +13,7 @@ const { evaluatePaymentStatus } = require('../services/paymentCircuitBreaker'); const { IA360_DEFAULT_PIPELINE_NAME, IA360_PARTNERS_PIPELINE_NAME, + IA360_PARTNER_RELATIONSHIPS, ia360PipelineForRelationship, ia360ResolveStageName, } = require('./ia360DealRouting'); @@ -4429,6 +4430,24 @@ async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceI payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; } await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + // G10 (P2): "Fit identificado" (pos 0 del pipeline Partners). Este es el momento + // PRE-ENVÍO: el owner eligió secuencia para un partner y se generó su readout/ + // draft, pero el opener AÚN no sale (eso ocurre en handleIa360OwnerApproveSend → + // "Introducción enviada", pos 1). Poblar pos 0 aquí deja el journey medible desde + // el inicio. No añade egress (syncIa360Deal es solo DB; el readout ya va al owner) + // y NO mueve el deal hacia atrás: "Fit identificado" no está en forceMoveStages y + // su position 0 nunca supera la de un deal ya avanzado (guard shouldMove de + // syncIa360Deal), así que un deal en "Introducción enviada"/posterior solo recibe + // una nota, no regresa de stage. + if (IA360_PARTNER_RELATIONSHIPS.has(String(flow?.relationshipContext || ''))) { + await syncIa360Deal({ + record, + targetStageName: 'Fit identificado', + titleSuffix: 'Fit (secuencia elegida)', + notes: `Partner identificado como fit: owner eligió secuencia ${sequence.id} (pre-envío).`, + pipelineName: ia360PipelineForRelationship(flow?.relationshipContext), + }).catch(e => console.error('[ia360-fit] syncIa360Deal:', e.message)); + } // G-COLD: aviso pre-aprobación. Si el contacto está fuera de la ventana de 24h, // el owner debe saber ANTES de aprobar si el opener saldrá como template de // Meta (mismo copy, con botones) o si no puede salir nada todavía. diff --git a/backend/test/ia360PartnerBlueprintStages.test.js b/backend/test/ia360PartnerBlueprintStages.test.js new file mode 100644 index 0000000..33dbf7f --- /dev/null +++ b/backend/test/ia360PartnerBlueprintStages.test.js @@ -0,0 +1,106 @@ +// G10 (P2): stages muertos del pipeline 6 (Partners / Aliados BNI) cableados al +// stage map, + secuencias Blueprint/Propuesta como STUB INERTE detrás de un flag +// que FALLA CERRADO. Este test requiere SOLO el módulo puro ia360DealRouting (no +// webhook.js, que necesitaría express/pool y no hay node_modules). +// +// Lo que se garantiza: +// - Flag default OFF: las secuencias no se ofrecen y nada es enviable, aunque el +// template "exista" (fail-closed por flag). +// - Flag ON pero sin template aprobado → no enviable (fail-closed por template). +// - El stage map alcanza "Fit identificado" (pos 0) y "Diagnóstico compartido" +// (pos 3) por sus tres vías (directo / Blueprint / Propuesta). +// - No-regresión: mapeos viejos y ruteo por relación intactos. +// +// El flag IA360_PARTNER_BLUEPRINT se evalúa en load-time (const), así que los +// helpers aceptan el estado del flag como parámetro inyectable para poder probar +// AMBOS lados sin recargar el proceso. +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + IA360_DEFAULT_PIPELINE_NAME, + IA360_PARTNERS_PIPELINE_NAME, + IA360_PARTNER_STAGE_MAP, + ia360PipelineForRelationship, + ia360ResolveStageName, + IA360_PARTNER_BLUEPRINT_ENABLED, + IA360_PARTNER_BLUEPRINT_SEQUENCES, + ia360PartnerBlueprintSequences, + ia360PartnerBlueprintSendable, +} = require('../src/routes/ia360DealRouting'); + +// ── Flag fail-closed ──────────────────────────────────────────────────────── +test('flag DEFAULT OFF: las secuencias Blueprint/Propuesta NO se ofrecen', () => { + // El test corre sin IA360_PARTNER_BLUEPRINT en el entorno → const = false. + assert.equal(IA360_PARTNER_BLUEPRINT_ENABLED, false); + assert.deepEqual(ia360PartnerBlueprintSequences(), []); +}); + +test('flag OFF: NADA es enviable aunque el template "exista" (fail-closed por flag)', () => { + const seq = IA360_PARTNER_BLUEPRINT_SEQUENCES[0]; + // Con el flag por default (OFF), incluso pasando el template correcto → false. + assert.equal(ia360PartnerBlueprintSendable(seq, seq.metaTemplateName), false); + assert.equal(ia360PartnerBlueprintSendable(seq, 'lo-que-sea'), false); + // Inyectando el flag OFF explícitamente: idéntico. + assert.equal(ia360PartnerBlueprintSendable(seq, seq.metaTemplateName, false), false); +}); + +test('flag ON: enviable SOLO con el template aprobado; sin template → no enviable', () => { + for (const seq of IA360_PARTNER_BLUEPRINT_SEQUENCES) { + // Con flag ON las secuencias sí se ofrecen. + assert.equal(ia360PartnerBlueprintSequences(true).length, IA360_PARTNER_BLUEPRINT_SEQUENCES.length); + // Template aprobado (coincide) → enviable. + assert.equal(ia360PartnerBlueprintSendable(seq, seq.metaTemplateName, true), true); + // Sin template → fail-closed (el corazón del stub: no hay template en Meta aún). + assert.equal(ia360PartnerBlueprintSendable(seq, '', true), false); + assert.equal(ia360PartnerBlueprintSendable(seq, null, true), false); + assert.equal(ia360PartnerBlueprintSendable(seq, undefined, true), false); + // Template distinto al de la secuencia → no enviable (no se cuela otro). + assert.equal(ia360PartnerBlueprintSendable(seq, 'ia360_otro_template', true), false); + } +}); + +test('catálogo Blueprint/Propuesta: ambas apuntan a "Diagnóstico compartido"', () => { + assert.equal(IA360_PARTNER_BLUEPRINT_SEQUENCES.length, 2); + for (const seq of IA360_PARTNER_BLUEPRINT_SEQUENCES) { + assert.equal(seq.stage, 'Diagnóstico compartido'); + assert.ok(seq.id && seq.label && seq.metaTemplateName, `secuencia incompleta: ${JSON.stringify(seq)}`); + } +}); + +// ── Stage map: los dos stages muertos ahora son alcanzables ───────────────── +test('"Fit identificado" (pos 0) alcanzable en el pipeline Partners', () => { + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Fit identificado'), 'Fit identificado'); +}); + +test('"Diagnóstico compartido" (pos 3) alcanzable por sus tres vías', () => { + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Diagnóstico compartido'), 'Diagnóstico compartido'); + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Blueprint compartido'), 'Diagnóstico compartido'); + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Propuesta enviada'), 'Diagnóstico compartido'); +}); + +// ── No-regresión: mapeos viejos intactos ──────────────────────────────────── +test('no-regresión: mapeos viejos del stage map intactos', () => { + assert.equal(IA360_PARTNER_STAGE_MAP['Diagnóstico enviado'], 'Introducción enviada'); + assert.equal(IA360_PARTNER_STAGE_MAP['Intención detectada'], 'Prospecto activo'); + assert.equal(IA360_PARTNER_STAGE_MAP['Agenda en proceso'], 'Seguimiento en marcha'); + assert.equal(IA360_PARTNER_STAGE_MAP['Requiere Alek'], 'Seguimiento en marcha'); + assert.equal(IA360_PARTNER_STAGE_MAP['Nutrición'], 'Prospecto activo'); + assert.equal(IA360_PARTNER_STAGE_MAP['Ganado'], 'Ganado'); + assert.equal(IA360_PARTNER_STAGE_MAP['Perdido / no fit'], 'Perdido'); + // Resolución vía función (espejo de los mapeos). + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Diagnóstico enviado'), 'Introducción enviada'); + assert.equal(ia360ResolveStageName(IA360_PARTNERS_PIPELINE_NAME, 'Requiere Alek'), 'Seguimiento en marcha'); +}); + +test('no-regresión: ruteo de pipeline por relación intacto', () => { + assert.equal(ia360PipelineForRelationship('aliado_socio'), IA360_PARTNERS_PIPELINE_NAME); + assert.equal(ia360PipelineForRelationship('referido_bni'), IA360_PARTNERS_PIPELINE_NAME); + assert.notEqual(ia360PipelineForRelationship('cliente_activo'), IA360_PARTNERS_PIPELINE_NAME); + assert.equal(ia360PipelineForRelationship('cliente_activo'), IA360_DEFAULT_PIPELINE_NAME); +}); + +test('no-regresión: pipeline genérico NO traduce stages', () => { + assert.equal(ia360ResolveStageName(IA360_DEFAULT_PIPELINE_NAME, 'Fit identificado'), 'Fit identificado'); + assert.equal(ia360ResolveStageName(IA360_DEFAULT_PIPELINE_NAME, 'Diagnóstico enviado'), 'Diagnóstico enviado'); +}); From d8f68eec16b0155cc83196b9f27d1246f97daea1 Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 16 Jun 2026 14:08:06 +0000 Subject: [PATCH 38/39] =?UTF-8?q?fix(ia360):=20G10=20=E2=80=94=20el=20deal?= =?UTF-8?q?=20Fit=20identificado=20va=20al=20contacto=20partner,=20no=20al?= =?UTF-8?q?=20owner=20(re-scope=20record=20a=20targetContact)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bot_events_backup.csv | 12 + .cleanup-backup-20260610/bot_facts_backup.csv | 18 + .cleanup-backup-20260610/deal5_backup.csv | 178 + .../owner_contact_backup.csv | 2 + .../owner_meeting_links_backup.csv | 4 + .forgechat-credentials | 6 + DEPLOYMENT-WA-GEEKSTUDIO.md | 69 + .../contact-sheet.jpg | Bin 0 -> 257636 bytes .../optimized/alek_presente.jpg | Bin 0 -> 68329 bytes .../optimized/bi_solucion.jpg | Bin 0 -> 79442 bytes .../optimized/dolor_ceo.jpg | Bin 0 -> 181293 bytes .../optimized/transformacion.jpg | Bin 0 -> 87023 bytes backend/.env.bak-approvesend-20260609T222412Z | 31 + backend/.env.bak-contact-intel-primary- | 25 + ...bak-contact-intel-primary-20260605T202652Z | 25 + .../.env.bak-eq0-directive-20260604T210515Z | 22 + backend/.env.bak-pre-agent-20260602T225247Z | 19 + backend/.env.bak-pre-appsecret-20260601 | 14 + backend/.env.bak-pre-n8nurls-20260602T215557Z | 19 + backend/.env.bak-wa-20260601T165910Z | 14 + backend/src/index.js.bak-b28-1780543839 | 214 + backend/src/index.js.bak-cal-1780939394 | 214 + backend/src/queue/sendQueue.js.bak-pre-gbrain | 198 + ...ueue.js.bak-pre-validator-20260609T194227Z | 180 + ...intake.js.bak-profilename-20260604T214651Z | 119 + backend/src/routes/webhook.js | 7 +- backend/src/routes/webhook.js.bak- | 3859 ++++++++ .../src/routes/webhook.js.bak-20260605213800 | 3851 ++++++++ .../src/routes/webhook.js.bak-20260605214238 | 3851 ++++++++ .../src/routes/webhook.js.bak-20260605220119 | 3857 ++++++++ .../routes/webhook.js.bak-2acita-1780528220 | 2646 +++++ .../src/routes/webhook.js.bak-b28-1780543839 | 3098 ++++++ .../webhook.js.bak-b29-order-20260605T145907 | 3532 +++++++ .../webhook.js.bak-b29-vcard-20260605T145515 | 3259 +++++++ .../webhook.js.bak-batchagenda-1780529842 | 2855 ++++++ .../routes/webhook.js.bak-callink-1780940718 | 5382 +++++++++++ .../webhook.js.bak-cancelfix-1780521963 | 2378 +++++ .../src/routes/webhook.js.bak-cfm-1780520261 | 2078 ++++ .../routes/webhook.js.bak-deltaB-1780513807 | 1755 ++++ .../src/routes/webhook.js.bak-e2-1780519288 | 2049 ++++ ...hook.js.bak-eq0-directive-20260604T210438Z | 3098 ++++++ .../routes/webhook.js.bak-feedback-1780528887 | 2666 +++++ .../routes/webhook.js.bak-flowwire-1780515705 | 1766 ++++ .../src/routes/webhook.js.bak-gate-1780521499 | 2375 +++++ .../src/routes/webhook.js.bak-gate-1780944997 | 5390 +++++++++++ ...ebhook.js.bak-gatefix-wantslist-1780532046 | 2932 ++++++ .../webhook.js.bak-ia360-timeout-20260608 | 5400 +++++++++++ .../webhook.js.bak-ideas-20260609T235617Z | 6233 ++++++++++++ ...k.js.bak-interactive-audit-20260608T145952 | 5363 ++++++++++ .../routes/webhook.js.bak-loglist-1780535301 | 2939 ++++++ .../webhook.js.bak-multicita-1780527013 | 2413 +++++ .../routes/webhook.js.bak-owner-1780521261 | 2183 +++++ .../webhook.js.bak-owner-pipe-20260605T152443 | 3534 +++++++ ....js.bak-persona-guardrails-20260605T173040 | 4413 +++++++++ ...webhook.js.bak-persona-seq-20260605T165643 | 3844 ++++++++ ...ebhook.js.bak-pre-aiagent-20260602T225239Z | 1550 +++ ...ok.js.bak-pre-approvesend-20260609T221906Z | 5945 ++++++++++++ ...js.bak-pre-brainv2-canary-20260609T190449Z | 5814 +++++++++++ ....js.bak-pre-coldstart-fix-20260603T010301Z | 1753 ++++ ...ok.js.bak-pre-confirmcopy-20260602T232502Z | 1707 ++++ ...ebhook.js.bak-pre-consola-20260610T152850Z | 6391 ++++++++++++ ...webhook.js.bak-pre-gbrain-20260611T221925Z | 8609 +++++++++++++++++ .../webhook.js.bak-pre-gc-20260610T182557Z | 6961 +++++++++++++ .../webhook.js.bak-pre-gcold-20260610T185011Z | 8058 +++++++++++++++ .../webhook.js.bak-pre-gd-20260610T192115Z | 7431 ++++++++++++++ .../webhook.js.bak-pre-gg-20260602T232921Z | 1707 ++++ .../webhook.js.bak-pre-glive-20260611T144600Z | 8293 ++++++++++++++++ ...bhook.js.bak-pre-nodouble-20260602T234257Z | 1729 ++++ ...hook.js.bak-pre-openersv2-20260610T171316Z | 6551 +++++++++++++ .../webhook.js.bak-pre-regex-20260602T234451Z | 1753 ++++ ...ook.js.bak-pre-reschedule-20260602T230345Z | 1698 ++++ ...bhook.js.bak-pre-revenueos-20260609T002702 | 5465 +++++++++++ ...ok.js.bak-pre-revert-fix2-20260603T011907Z | 1767 ++++ ...hook.js.bak-pre-validator-20260609T194227Z | 5918 +++++++++++ ...ook.js.bak-qa-persona-hint-20260605T174515 | 4627 +++++++++ .../webhook.js.bak-quickwins-1780535250 | 2936 ++++++ .../src/routes/webhook.js.bak-w4-1780537976 | 2940 ++++++ .../webhook.js.bak-w4loopfix-1780539588 | 3071 ++++++ .../webhook.js.bak.gap15.20260608_222354 | 5418 +++++++++++ ...r.js.bak-interactive-audit-20260608T145952 | 124 + .../services/messageSender.js.bak-pre-gbrain | 124 + .../webhook.js.bak-pre-gwin-20260610175408 | 7633 +++++++++++++++ frontend/public/ia360-bca/alek_presente.jpg | Bin 0 -> 68329 bytes frontend/public/ia360-bca/bi_solucion.jpg | Bin 0 -> 79442 bytes frontend/public/ia360-bca/dolor_ceo.jpg | Bin 0 -> 181293 bytes frontend/public/ia360-bca/transformacion.jpg | Bin 0 -> 87023 bytes ...a360-whatsapp-handoff-active.workflow.json | 99 + ...ia360-whatsapp-handoff-draft.workflow.json | 265 + ...reate-ia360-email-reply-router-dry-run.sql | 53 + out/dedupe-alek-forgechat.sql | 28 + ...rgechat-alek-contacts-20260602T152324Z.sql | 0 ...rgechat-alek-contacts-20260602T152337Z.csv | 3 + .../forgechat-alek-deals-20260602T152337Z.csv | 57 + out/ia360-handoff-before-update-20260602.json | 1 + ...a360-100m-dolor-ejecutivo-ceo-agobiado.jpg | Bin 0 -> 114226 bytes .../ia360-100m-mecanismo-whatsapp-crm-bi.jpg | Bin 0 -> 103770 bytes .../forgechat-chats-persona-first-owner.png | Bin 0 -> 154210 bytes ...chat-owner-chat-persona-first-readouts.png | Bin 0 -> 180162 bytes ...blic-owner-chat-aliado-guarded-readout.png | Bin 0 -> 164980 bytes ...t-persona-first-readouts-authenticated.png | Bin 0 -> 170355 bytes ...blic-owner-chat-persona-first-readouts.png | Bin 0 -> 144596 bytes ...blic-owner-chat-search-qa-personafirst.png | Bin 0 -> 169697 bytes ...lic-owner-chat-tecnico-guarded-readout.png | Bin 0 -> 163153 bytes out/register-alek-negative-feedback.sql | 16 + out/repair-alek-deals.sql | 34 + ...k-deals-before-repair-20260602T155518Z.csv | 58 + out/update-alek-contact-forgechat.sql | 28 + out/update-ia360-handoff-history.sql | 2 + out/update-ia360-handoff-workflow.sql | 2 + out/upsert-ia360-whatsapp-templates.sql | 76 + revenue-os-e2e.sh | 152 + scripts/ia360_full_funnel_dry_run.py | 165 + 112 files changed, 217400 insertions(+), 1 deletion(-) create mode 100644 .cleanup-backup-20260610/bot_events_backup.csv create mode 100644 .cleanup-backup-20260610/bot_facts_backup.csv create mode 100644 .cleanup-backup-20260610/deal5_backup.csv create mode 100644 .cleanup-backup-20260610/owner_contact_backup.csv create mode 100644 .cleanup-backup-20260610/owner_meeting_links_backup.csv create mode 100644 .forgechat-credentials create mode 100644 DEPLOYMENT-WA-GEEKSTUDIO.md create mode 100644 assets/ia360-bca-template-candidates/contact-sheet.jpg create mode 100644 assets/ia360-bca-template-candidates/optimized/alek_presente.jpg create mode 100644 assets/ia360-bca-template-candidates/optimized/bi_solucion.jpg create mode 100644 assets/ia360-bca-template-candidates/optimized/dolor_ceo.jpg create mode 100644 assets/ia360-bca-template-candidates/optimized/transformacion.jpg create mode 100644 backend/.env.bak-approvesend-20260609T222412Z create mode 100644 backend/.env.bak-contact-intel-primary- create mode 100644 backend/.env.bak-contact-intel-primary-20260605T202652Z create mode 100644 backend/.env.bak-eq0-directive-20260604T210515Z create mode 100644 backend/.env.bak-pre-agent-20260602T225247Z create mode 100644 backend/.env.bak-pre-appsecret-20260601 create mode 100644 backend/.env.bak-pre-n8nurls-20260602T215557Z create mode 100644 backend/.env.bak-wa-20260601T165910Z create mode 100644 backend/src/index.js.bak-b28-1780543839 create mode 100644 backend/src/index.js.bak-cal-1780939394 create mode 100644 backend/src/queue/sendQueue.js.bak-pre-gbrain create mode 100644 backend/src/queue/sendQueue.js.bak-pre-validator-20260609T194227Z create mode 100644 backend/src/routes/ia360-intake.js.bak-profilename-20260604T214651Z create mode 100644 backend/src/routes/webhook.js.bak- create mode 100644 backend/src/routes/webhook.js.bak-20260605213800 create mode 100644 backend/src/routes/webhook.js.bak-20260605214238 create mode 100644 backend/src/routes/webhook.js.bak-20260605220119 create mode 100644 backend/src/routes/webhook.js.bak-2acita-1780528220 create mode 100644 backend/src/routes/webhook.js.bak-b28-1780543839 create mode 100644 backend/src/routes/webhook.js.bak-b29-order-20260605T145907 create mode 100644 backend/src/routes/webhook.js.bak-b29-vcard-20260605T145515 create mode 100644 backend/src/routes/webhook.js.bak-batchagenda-1780529842 create mode 100644 backend/src/routes/webhook.js.bak-callink-1780940718 create mode 100644 backend/src/routes/webhook.js.bak-cancelfix-1780521963 create mode 100644 backend/src/routes/webhook.js.bak-cfm-1780520261 create mode 100644 backend/src/routes/webhook.js.bak-deltaB-1780513807 create mode 100644 backend/src/routes/webhook.js.bak-e2-1780519288 create mode 100644 backend/src/routes/webhook.js.bak-eq0-directive-20260604T210438Z create mode 100644 backend/src/routes/webhook.js.bak-feedback-1780528887 create mode 100644 backend/src/routes/webhook.js.bak-flowwire-1780515705 create mode 100644 backend/src/routes/webhook.js.bak-gate-1780521499 create mode 100644 backend/src/routes/webhook.js.bak-gate-1780944997 create mode 100644 backend/src/routes/webhook.js.bak-gatefix-wantslist-1780532046 create mode 100644 backend/src/routes/webhook.js.bak-ia360-timeout-20260608 create mode 100644 backend/src/routes/webhook.js.bak-ideas-20260609T235617Z create mode 100644 backend/src/routes/webhook.js.bak-interactive-audit-20260608T145952 create mode 100644 backend/src/routes/webhook.js.bak-loglist-1780535301 create mode 100644 backend/src/routes/webhook.js.bak-multicita-1780527013 create mode 100644 backend/src/routes/webhook.js.bak-owner-1780521261 create mode 100644 backend/src/routes/webhook.js.bak-owner-pipe-20260605T152443 create mode 100644 backend/src/routes/webhook.js.bak-persona-guardrails-20260605T173040 create mode 100644 backend/src/routes/webhook.js.bak-persona-seq-20260605T165643 create mode 100644 backend/src/routes/webhook.js.bak-pre-aiagent-20260602T225239Z create mode 100644 backend/src/routes/webhook.js.bak-pre-approvesend-20260609T221906Z create mode 100644 backend/src/routes/webhook.js.bak-pre-brainv2-canary-20260609T190449Z create mode 100644 backend/src/routes/webhook.js.bak-pre-coldstart-fix-20260603T010301Z create mode 100644 backend/src/routes/webhook.js.bak-pre-confirmcopy-20260602T232502Z create mode 100644 backend/src/routes/webhook.js.bak-pre-consola-20260610T152850Z create mode 100644 backend/src/routes/webhook.js.bak-pre-gbrain-20260611T221925Z create mode 100644 backend/src/routes/webhook.js.bak-pre-gc-20260610T182557Z create mode 100644 backend/src/routes/webhook.js.bak-pre-gcold-20260610T185011Z create mode 100644 backend/src/routes/webhook.js.bak-pre-gd-20260610T192115Z create mode 100644 backend/src/routes/webhook.js.bak-pre-gg-20260602T232921Z create mode 100644 backend/src/routes/webhook.js.bak-pre-glive-20260611T144600Z create mode 100644 backend/src/routes/webhook.js.bak-pre-nodouble-20260602T234257Z create mode 100644 backend/src/routes/webhook.js.bak-pre-openersv2-20260610T171316Z create mode 100644 backend/src/routes/webhook.js.bak-pre-regex-20260602T234451Z create mode 100644 backend/src/routes/webhook.js.bak-pre-reschedule-20260602T230345Z create mode 100644 backend/src/routes/webhook.js.bak-pre-revenueos-20260609T002702 create mode 100644 backend/src/routes/webhook.js.bak-pre-revert-fix2-20260603T011907Z create mode 100644 backend/src/routes/webhook.js.bak-pre-validator-20260609T194227Z create mode 100644 backend/src/routes/webhook.js.bak-qa-persona-hint-20260605T174515 create mode 100644 backend/src/routes/webhook.js.bak-quickwins-1780535250 create mode 100644 backend/src/routes/webhook.js.bak-w4-1780537976 create mode 100644 backend/src/routes/webhook.js.bak-w4loopfix-1780539588 create mode 100644 backend/src/routes/webhook.js.bak.gap15.20260608_222354 create mode 100644 backend/src/services/messageSender.js.bak-interactive-audit-20260608T145952 create mode 100644 backend/src/services/messageSender.js.bak-pre-gbrain create mode 100644 backend/webhook.js.bak-pre-gwin-20260610175408 create mode 100644 frontend/public/ia360-bca/alek_presente.jpg create mode 100644 frontend/public/ia360-bca/bi_solucion.jpg create mode 100644 frontend/public/ia360-bca/dolor_ceo.jpg create mode 100644 frontend/public/ia360-bca/transformacion.jpg create mode 100644 n8n-ia360-whatsapp-handoff-active.workflow.json create mode 100644 n8n-ia360-whatsapp-handoff-draft.workflow.json create mode 100644 out/create-ia360-email-reply-router-dry-run.sql create mode 100644 out/dedupe-alek-forgechat.sql create mode 100644 out/dedupe-backups/forgechat-alek-contacts-20260602T152324Z.sql create mode 100644 out/dedupe-backups/forgechat-alek-contacts-20260602T152337Z.csv create mode 100644 out/dedupe-backups/forgechat-alek-deals-20260602T152337Z.csv create mode 100644 out/ia360-handoff-before-update-20260602.json create mode 100644 out/ia360-media-tests/ia360-100m-dolor-ejecutivo-ceo-agobiado.jpg create mode 100644 out/ia360-media-tests/ia360-100m-mecanismo-whatsapp-crm-bi.jpg create mode 100644 out/persona-first-qa/forgechat-chats-persona-first-owner.png create mode 100644 out/persona-first-qa/forgechat-owner-chat-persona-first-readouts.png create mode 100644 out/persona-first-qa/forgechat-public-owner-chat-aliado-guarded-readout.png create mode 100644 out/persona-first-qa/forgechat-public-owner-chat-persona-first-readouts-authenticated.png create mode 100644 out/persona-first-qa/forgechat-public-owner-chat-persona-first-readouts.png create mode 100644 out/persona-first-qa/forgechat-public-owner-chat-search-qa-personafirst.png create mode 100644 out/persona-first-qa/forgechat-public-owner-chat-tecnico-guarded-readout.png create mode 100644 out/register-alek-negative-feedback.sql create mode 100644 out/repair-alek-deals.sql create mode 100644 out/repair-backups/alek-deals-before-repair-20260602T155518Z.csv create mode 100644 out/update-alek-contact-forgechat.sql create mode 100644 out/update-ia360-handoff-history.sql create mode 100644 out/update-ia360-handoff-workflow.sql create mode 100644 out/upsert-ia360-whatsapp-templates.sql create mode 100644 revenue-os-e2e.sh create mode 100644 scripts/ia360_full_funnel_dry_run.py diff --git a/.cleanup-backup-20260610/bot_events_backup.csv b/.cleanup-backup-20260610/bot_events_backup.csv new file mode 100644 index 0000000..6686f34 --- /dev/null +++ b/.cleanup-backup-20260610/bot_events_backup.csv @@ -0,0 +1,12 @@ +id,schema_version,source,contact_wa_number,contact_number,forgechat_contact_id,espo_contact_id,contact_name,contact_role,account_name,project_name,persona,lifecycle_stage,area,signal_type,confidence,summary,business_impact,missing_data,next_action,should_be_fact,crm_sync_status,rag_index_status,owner_review_status,external_send_allowed,contains_sensitive_data,store_transcript,source_message_id,source_chat_history_id,payload,created_at,updated_at +5,ia360_memory_event.v1,whatsapp,5213321594582,5210000002997,,,QA IA360 n8n Memory,,,,,,cartera_cobranza_portal,dolor_operativo,0.880,"Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.",Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.,"Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.",Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.,t,dry_run_compact,structured_lookup_ready,required,f,f,f,qa-n8n-memory-local-20260606-001:cartera_cobranza_portal,,"{""sync"": {""crm_sync_status"": ""dry_run_compact"", ""rag_index_status"": ""structured_lookup_ready"", ""owner_review_status"": ""required""}, ""schema"": ""ia360_memory_event.v1"", ""source"": ""whatsapp"", ""account"": {""name"": """", ""project"": """"}, ""contact"": {""name"": ""QA IA360 n8n Memory"", ""role"": """", ""espo_contact_id"": """", ""forgechat_contact_id"": """"}, ""learning"": {""summary"": ""Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas."", ""next_action"": ""Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento."", ""missing_data"": ""Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual."", ""should_be_fact"": true, ""business_impact"": ""Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.""}, ""guardrails"": {""store_transcript"": false, ""external_send_allowed"": false, ""contains_sensitive_data"": false}, ""classification"": {""area"": ""cartera_cobranza_portal"", ""persona"": """", ""confidence"": 0.88, ""signal_type"": ""dolor_operativo"", ""lifecycle_stage"": """"}}",2026-06-06 02:28:49.699907+00,2026-06-06 02:28:49.699907+00 +1,ia360_memory_event.v1,whatsapp,5213321594582,5210000002999,,,QA IA360 Memory,,,,,,taller_garantia_dias_detencion,dolor_operativo,0.910,QA sintética: taller necesita visibilidad de días detenidos y responsable.,Evita escalaciones por unidades detenidas.,"Unidad, bloqueo, responsable y días detenida.",Mapear unidad detenida -> bloqueo -> responsable -> decisión.,t,dry_run_compact,structured_lookup_ready,required,f,f,f,qa-ia360-memory-endpoint-001,,"{""sync"": {""crm_sync_status"": ""dry_run_compact"", ""rag_index_status"": ""structured_lookup_ready"", ""owner_review_status"": ""required""}, ""schema"": ""ia360_memory_event.v1"", ""source"": ""whatsapp"", ""account"": {""name"": """", ""project"": """"}, ""contact"": {""name"": ""QA IA360 Memory"", ""role"": """", ""espo_contact_id"": """", ""forgechat_contact_id"": """"}, ""learning"": {""summary"": ""QA sintética: taller necesita visibilidad de días detenidos y responsable."", ""next_action"": ""Mapear unidad detenida -> bloqueo -> responsable -> decisión."", ""missing_data"": ""Unidad, bloqueo, responsable y días detenida."", ""should_be_fact"": true, ""business_impact"": ""Evita escalaciones por unidades detenidas.""}, ""guardrails"": {""store_transcript"": false, ""external_send_allowed"": false, ""contains_sensitive_data"": false}, ""classification"": {""area"": ""taller_garantia_dias_detencion"", ""persona"": """", ""confidence"": 0.91, ""signal_type"": ""dolor_operativo"", ""lifecycle_stage"": """"}}",2026-06-06 02:05:35.758293+00,2026-06-06 02:05:35.758293+00 +7,ia360_memory_event.v1,whatsapp,5213321594582,5213321060293,544,6a233922ec5fcb4d0,Andres Camiones Selectos,cliente_activo_cfo_champion,Camiones Selectos,Camiones Selectos,Cliente activo,cliente_activo,cartera_cobranza_portal,dolor_operativo,0.880,Portal de cartera muestra saldos incorrectos; Andrés quiere compartir los saldos correctos para corregir el seguimiento.,Evita seguimiento financiero con saldos equivocados y mejora decisiones de cobranza en el portal.,"Archivo o tabla con cliente/cuenta, saldo actual mostrado, saldo correcto, fecha de corte y responsable de validación.",Pedir a Andrés que suba o comparta la tabla de saldos correctos; convertirla en mapa cartera -> saldo actual -> saldo correcto -> responsable -> siguiente acción.,t,dry_run_compact,structured_lookup_ready,required,f,f,f,wamid.HBgNNTIxMzMyMTA2MDI5MxUCABIYFjNFQjA2RUJEMzFFMDc3RjEzN0M3MDEA,,"{""sync"": {""crm_sync_status"": ""dry_run_compact"", ""rag_index_status"": ""structured_lookup_ready"", ""owner_review_status"": ""required""}, ""schema"": ""ia360_memory_event.v1"", ""source"": ""whatsapp"", ""account"": {""name"": ""Camiones Selectos"", ""project"": ""Camiones Selectos""}, ""contact"": {""name"": ""Andres Camiones Selectos"", ""role"": ""cliente_activo_cfo_champion"", ""espo_contact_id"": ""6a233922ec5fcb4d0"", ""forgechat_contact_id"": ""544""}, ""learning"": {""summary"": ""Portal de cartera muestra saldos incorrectos; Andrés quiere compartir los saldos correctos para corregir el seguimiento."", ""next_action"": ""Pedir a Andrés que suba o comparta la tabla de saldos correctos; convertirla en mapa cartera -> saldo actual -> saldo correcto -> responsable -> siguiente acción."", ""missing_data"": ""Archivo o tabla con cliente/cuenta, saldo actual mostrado, saldo correcto, fecha de corte y responsable de validación."", ""should_be_fact"": true, ""business_impact"": ""Evita seguimiento financiero con saldos equivocados y mejora decisiones de cobranza en el portal.""}, ""guardrails"": {""store_transcript"": false, ""external_send_allowed"": false, ""contains_sensitive_data"": false}, ""classification"": {""area"": ""cartera_cobranza_portal"", ""persona"": ""Cliente activo"", ""confidence"": 0.88, ""signal_type"": ""dolor_operativo"", ""lifecycle_stage"": ""cliente_activo""}}",2026-06-06 15:53:17.348232+00,2026-06-06 16:37:08.880445+00 +6,ia360_memory_event.v1,whatsapp,5213321594582,5213321060293,544,6a233922ec5fcb4d0,Andres Camiones Selectos,cliente_activo_cfo_champion,Camiones Selectos,Camiones Selectos,Cliente activo,cliente_activo,taller_garantia_dias_detencion,dolor_operativo,0.900,"Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.",Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.,"Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.",Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.,t,dry_run_compact,structured_lookup_ready,required,f,f,f,wamid.HBgNNTIxMzMyMTA2MDI5MxUCABIYFjNFQjAzOERFOTY3OEExREZCN0M5MkMA,,"{""sync"": {""crm_sync_status"": ""dry_run_compact"", ""rag_index_status"": ""structured_lookup_ready"", ""owner_review_status"": ""required""}, ""schema"": ""ia360_memory_event.v1"", ""source"": ""whatsapp"", ""account"": {""name"": ""Camiones Selectos"", ""project"": ""Camiones Selectos""}, ""contact"": {""name"": ""Andres Camiones Selectos"", ""role"": ""cliente_activo_cfo_champion"", ""espo_contact_id"": ""6a233922ec5fcb4d0"", ""forgechat_contact_id"": ""544""}, ""learning"": {""summary"": ""Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación."", ""next_action"": ""Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente."", ""missing_data"": ""Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado."", ""should_be_fact"": true, ""business_impact"": ""Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.""}, ""guardrails"": {""store_transcript"": false, ""external_send_allowed"": false, ""contains_sensitive_data"": false}, ""classification"": {""area"": ""taller_garantia_dias_detencion"", ""persona"": ""Cliente activo"", ""confidence"": 0.9, ""signal_type"": ""dolor_operativo"", ""lifecycle_stage"": ""cliente_activo""}}",2026-06-06 15:27:05.352266+00,2026-06-06 15:27:05.352266+00 +4,ia360_memory_event.v1,whatsapp,5213321594582,5213321060293,544,6a233922ec5fcb4d0,Andres Camiones Selectos,cliente_activo_cfo_champion,Camiones Selectos,Camiones Selectos,Cliente activo,cliente_activo,auditoria_licencias_gasto,dolor_operativo,0.890,Licencias/gasto necesita comparar licencias pagadas contra uso real y evaluar consultas apoyadas por IA.,Puede reducir gasto recurrente sin perder acceso a información operativa.,"Sistema, licencias pagadas, usuarios activos, consultas necesarias y permisos mínimos.",Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.,t,dry_run_compact,structured_lookup_ready,required,f,f,f,andres-auditoria-licencias-gasto-20260606,,"{""sync"": {""crm_sync_status"": ""dry_run_compact"", ""rag_index_status"": ""structured_lookup_ready"", ""owner_review_status"": ""required""}, ""schema"": ""ia360_memory_event.v1"", ""source"": ""whatsapp"", ""account"": {""name"": ""Camiones Selectos"", ""project"": ""Camiones Selectos""}, ""contact"": {""name"": ""Andres Camiones Selectos"", ""role"": ""cliente_activo_cfo_champion"", ""espo_contact_id"": ""6a233922ec5fcb4d0"", ""forgechat_contact_id"": ""544""}, ""learning"": {""summary"": ""Licencias/gasto necesita comparar licencias pagadas contra uso real y evaluar consultas apoyadas por IA."", ""next_action"": ""Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible."", ""missing_data"": ""Sistema, licencias pagadas, usuarios activos, consultas necesarias y permisos mínimos."", ""should_be_fact"": true, ""business_impact"": ""Puede reducir gasto recurrente sin perder acceso a información operativa.""}, ""guardrails"": {""store_transcript"": false, ""external_send_allowed"": false, ""contains_sensitive_data"": false}, ""classification"": {""area"": ""auditoria_licencias_gasto"", ""persona"": ""Cliente activo"", ""confidence"": 0.89, ""signal_type"": ""dolor_operativo"", ""lifecycle_stage"": ""cliente_activo""}}",2026-06-06 02:06:12.072411+00,2026-06-06 02:06:12.072411+00 +3,ia360_memory_event.v1,whatsapp,5213321594582,5213321060293,544,6a233922ec5fcb4d0,Andres Camiones Selectos,cliente_activo_cfo_champion,Camiones Selectos,Camiones Selectos,Cliente activo,cliente_activo,taller_garantia_dias_detencion,dolor_operativo,0.940,"Taller necesita medir días de unidad detenida, tipo de caso, bloqueo, responsable y costo para cliente.",Evita que una garantía barata o demora operativa escale por pérdida mayor de operación del cliente.,"Unidad, días detenida, bloqueo, responsable, tipo de caso y costo estimado de inactividad.",Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.,t,dry_run_compact,structured_lookup_ready,required,f,f,f,andres-taller-garantia-dias-detencion-20260606,,"{""sync"": {""crm_sync_status"": ""dry_run_compact"", ""rag_index_status"": ""structured_lookup_ready"", ""owner_review_status"": ""required""}, ""schema"": ""ia360_memory_event.v1"", ""source"": ""whatsapp"", ""account"": {""name"": ""Camiones Selectos"", ""project"": ""Camiones Selectos""}, ""contact"": {""name"": ""Andres Camiones Selectos"", ""role"": ""cliente_activo_cfo_champion"", ""espo_contact_id"": ""6a233922ec5fcb4d0"", ""forgechat_contact_id"": ""544""}, ""learning"": {""summary"": ""Taller necesita medir días de unidad detenida, tipo de caso, bloqueo, responsable y costo para cliente."", ""next_action"": ""Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente."", ""missing_data"": ""Unidad, días detenida, bloqueo, responsable, tipo de caso y costo estimado de inactividad."", ""should_be_fact"": true, ""business_impact"": ""Evita que una garantía barata o demora operativa escale por pérdida mayor de operación del cliente.""}, ""guardrails"": {""store_transcript"": false, ""external_send_allowed"": false, ""contains_sensitive_data"": false}, ""classification"": {""area"": ""taller_garantia_dias_detencion"", ""persona"": ""Cliente activo"", ""confidence"": 0.94, ""signal_type"": ""dolor_operativo"", ""lifecycle_stage"": ""cliente_activo""}}",2026-06-06 02:06:12.06301+00,2026-06-06 02:06:12.06301+00 +2,ia360_memory_event.v1,whatsapp,5213321594582,5213321060293,544,6a233922ec5fcb4d0,Andres Camiones Selectos,cliente_activo_cfo_champion,Camiones Selectos,Camiones Selectos,Cliente activo,cliente_activo,cartera_cobranza_portal,dolor_operativo,0.920,"Cartera necesita capturar comentarios, fechas compromiso, pasos internos del cliente y seguimiento desde portal.","Reduce dependencia de Excel, llamadas y coordinación dispersa para seguimiento financiero.","Cuenta, comentario vigente, fecha compromiso, responsable interno y siguiente paso.",Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.,t,dry_run_compact,structured_lookup_ready,required,f,f,f,andres-cartera-cobranza-portal-20260606,,"{""sync"": {""crm_sync_status"": ""dry_run_compact"", ""rag_index_status"": ""structured_lookup_ready"", ""owner_review_status"": ""required""}, ""schema"": ""ia360_memory_event.v1"", ""source"": ""whatsapp"", ""account"": {""name"": ""Camiones Selectos"", ""project"": ""Camiones Selectos""}, ""contact"": {""name"": ""Andres Camiones Selectos"", ""role"": ""cliente_activo_cfo_champion"", ""espo_contact_id"": ""6a233922ec5fcb4d0"", ""forgechat_contact_id"": ""544""}, ""learning"": {""summary"": ""Cartera necesita capturar comentarios, fechas compromiso, pasos internos del cliente y seguimiento desde portal."", ""next_action"": ""Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento."", ""missing_data"": ""Cuenta, comentario vigente, fecha compromiso, responsable interno y siguiente paso."", ""should_be_fact"": true, ""business_impact"": ""Reduce dependencia de Excel, llamadas y coordinación dispersa para seguimiento financiero.""}, ""guardrails"": {""store_transcript"": false, ""external_send_allowed"": false, ""contains_sensitive_data"": false}, ""classification"": {""area"": ""cartera_cobranza_portal"", ""persona"": ""Cliente activo"", ""confidence"": 0.92, ""signal_type"": ""dolor_operativo"", ""lifecycle_stage"": ""cliente_activo""}}",2026-06-06 02:06:12.042387+00,2026-06-06 02:06:12.042387+00 +23,ia360_memory_event.v1,owner_context_load,5213321594582,5213339499453,908,,Emmanuel Orozco,owner_multinegocio,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo,canal_whatsapp,mejoras_propuestas,0.950,"Mejoras proponibles a Emmanuel ancladas en capacidades ya construidas: (1) reactivación por WhatsApp de los 65 clientes en riesgo y 87 dormidos — hay teléfono y perfil de consumo de cada uno; (2) el bot reconoce al cliente recurrente por su teléfono al escribir (último pedido, frecuencia, gap) y personaliza la atención y el upsell; (3) promociones dirigidas al valle de ventas 17:00-18:30 por segmento; (4) cada pedido tomado por WhatsApp nace con teléfono identificado, alimentando el motor de retención sin depender del campo de texto libre del POS.",Recuperar una fracción de los 152 clientes en riesgo+dormidos con ticket promedio de $242 es revenue inmediato; la identidad por WhatsApp corrige de raíz la principal debilidad de datos de su POS.,Aprobación de Emmanuel para contactar segmentos; copy de reactivación; reglas de oferta por segmento.,Proponerle arrancar la prueba del bot usando el segmento en riesgo (65 clientes) como piloto de reactivación medible.,t,not_synced,not_indexed,owner_approved,f,f,f,,,"{""source"": ""retencion_clientes.json + chat ids 971-988""}",2026-06-09 23:53:05.938489+00,2026-06-09 23:53:05.938489+00 +22,ia360_memory_event.v1,owner_context_load,5213321594582,5213339499453,908,,Emmanuel Orozco,owner_multinegocio,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo,canal_whatsapp,upsell_candidates,0.920,"Emmanuel pidió upselling en el bot (9-jun). Candidatos desde datos reales del POS: combo mitad-mitad favorito Hawaiana+Pepperoni (268 pedidos, el doble del segundo), alitas, breadsticks, refresco 2L y combos confirmados; ticket promedio actual $242 como base a subir.","El upselling sube el ticket promedio sin tráfico nuevo; los candidatos salen de su propio comportamiento de compra, no de suposiciones.",Que Emmanuel valide 3-5 upsells prioritarios y las reglas sí/no que el bot debe respetar.,Proponerle a Emmanuel la lista de upsells derivada del POS y que él la recorte/ajuste.,t,not_synced,not_indexed,owner_approved,f,f,f,,,"{""source"": ""productos_analisis.json + chat ids 971-974""}",2026-06-09 23:43:33.722347+00,2026-06-09 23:43:33.722347+00 +19,ia360_memory_event.v1,owner_context_load,5213321594582,5213339499453,908,,Emmanuel Orozco,owner_multinegocio,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo,canal_whatsapp,solicitud_actual,0.970,"Emmanuel pidió (9-jun-2026) un canal de WhatsApp del negocio atendido en automático: dos rutas (prospectos y clientes), que tome pedidos y venda, SIN canalizar a humanos. El canal hoy no existe.",Conecta directo con su problema de retención (62.6% de clientes de una sola compra) y con el valle de ventas 17:00-18:30; un vendedor automático por WhatsApp puede reactivar a los 997 inactivos.,"Menú y precios por sucursal, número de WhatsApp a habilitar, zonas de entrega, reglas de cotización.",Diseñar el flujo de pedidos y venta por WhatsApp con dos rutas (prospecto/cliente) y presentárselo aterrizado a su operación de 7 sucursales.,t,not_synced,not_indexed,owner_approved,f,f,f,,,"{""source_chat_history"": ""ids 948-970""}",2026-06-09 23:27:37.101949+00,2026-06-09 23:27:37.101949+00 +20,ia360_memory_event.v1,owner_context_load,5213321594582,5213339499453,908,,Emmanuel Orozco,owner_multinegocio,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo,transformacion_digital,estado_proyecto,0.950,Estado del proyecto al 17-abr-2026: dashboard de analytics v2 (con food cost) entregado en su Drive; CAPTA 10/24 listo; La Barca offline bloquea las comparativas año contra año; pendiente .exe del ETL para su máquina.,"Emmanuel ya tiene visibilidad de ventas, clientes y food cost que antes no existía; los hallazgos (descuentos sin control, retención) son palancas de decisión inmediatas.","Metas por sucursal, Excel de costos, fechas de ferias locales, encendido de la máquina de La Barca.",Cuando conecte La Barca: correr el ETL y actualizar el dashboard con comparativas multi-sucursal reales.,t,not_synced,not_indexed,owner_approved,f,f,f,,,"{""source"": ""handover-proxima-sesion-la-barca.md""}",2026-06-09 23:27:37.101949+00,2026-06-09 23:27:37.101949+00 diff --git a/.cleanup-backup-20260610/bot_facts_backup.csv b/.cleanup-backup-20260610/bot_facts_backup.csv new file mode 100644 index 0000000..ef8fb5d --- /dev/null +++ b/.cleanup-backup-20260610/bot_facts_backup.csv @@ -0,0 +1,18 @@ +id,schema_version,fact_key,source_event_id,source,contact_wa_number,contact_number,forgechat_contact_id,espo_contact_id,account_name,project_name,persona,role,preference,objection,recurring_pain,affected_process,missing_metric,confidence,owner_review_status,status,evidence_count,payload,first_seen_at,last_seen_at,updated_at +1,ia360_memory_fact.v1,b476bf0bfdbf53bfd893d266ea8ccde0,1,whatsapp,5213321594582,5210000002999,,,,,,,"","",QA sintética: taller necesita visibilidad de días detenidos y responsable.,taller_garantia_dias_detencion,"Unidad, bloqueo, responsable y días detenida.",0.910,pending_owner_review,pending_owner_review,1,"{""role"": """", ""schema"": ""ia360_memory_fact.v1"", ""source"": ""qa-ia360-memory-endpoint-001"", ""persona"": """", ""objection"": """", ""confidence"": 0.91, ""preference"": """", ""missing_metric"": ""Unidad, bloqueo, responsable y días detenida."", ""recurring_pain"": ""QA sintética: taller necesita visibilidad de días detenidos y responsable."", ""affected_process"": ""taller_garantia_dias_detencion""}",2026-06-06 02:05:35.760289+00,2026-06-06 02:05:35.760289+00,2026-06-06 02:05:35.760289+00 +4,ia360_memory_fact.v1,d1d16f318f919bc1adb4bfff70491086,4,whatsapp,5213321594582,5213321060293,544,6a233922ec5fcb4d0,Camiones Selectos,Camiones Selectos,Cliente activo,cliente_activo_cfo_champion,"Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.","",Licencias/gasto necesita comparar licencias pagadas contra uso real y evaluar consultas apoyadas por IA.,auditoria_licencias_gasto,"Sistema, licencias pagadas, usuarios activos, consultas necesarias y permisos mínimos.",0.890,pending_owner_review,pending_owner_review,1,"{""role"": ""cliente_activo_cfo_champion"", ""schema"": ""ia360_memory_fact.v1"", ""source"": ""andres-auditoria-licencias-gasto-20260606"", ""persona"": ""Cliente activo"", ""objection"": """", ""confidence"": 0.89, ""preference"": ""Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default."", ""missing_metric"": ""Sistema, licencias pagadas, usuarios activos, consultas necesarias y permisos mínimos."", ""recurring_pain"": ""Licencias/gasto necesita comparar licencias pagadas contra uso real y evaluar consultas apoyadas por IA."", ""affected_process"": ""auditoria_licencias_gasto""}",2026-06-06 02:06:12.073485+00,2026-06-06 02:06:12.073485+00,2026-06-06 02:06:12.073485+00 +5,ia360_memory_fact.v1,3ab1d654bc62ef410a0eb887e4ca035f,5,whatsapp,5213321594582,5210000002997,,,,,,,"","","Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.",cartera_cobranza_portal,"Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.",0.880,pending_owner_review,pending_owner_review,1,"{""role"": """", ""schema"": ""ia360_memory_fact.v1"", ""source"": ""qa-n8n-memory-local-20260606-001:cartera_cobranza_portal"", ""persona"": """", ""objection"": """", ""confidence"": 0.88, ""preference"": """", ""missing_metric"": ""Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual."", ""recurring_pain"": ""Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas."", ""affected_process"": ""cartera_cobranza_portal""}",2026-06-06 02:28:49.701433+00,2026-06-06 02:28:49.701433+00,2026-06-06 02:28:49.701433+00 +2,ia360_memory_fact.v1,72ae9a8a8e5097dcfbdc37d06fdf0169,7,whatsapp,5213321594582,5213321060293,544,6a233922ec5fcb4d0,Camiones Selectos,Camiones Selectos,Cliente activo,cliente_activo_cfo_champion,"Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.","","Portal de cartera muestra saldos incorrectos y cobranza necesita comentarios, compromisos y seguimiento visibles con datos confiables.",cartera_cobranza_portal,"Cliente/cuenta, saldo mostrado, saldo correcto, fecha de corte, responsable de validación y siguiente acción.",0.920,pending_owner_review,pending_owner_review,2,"{""role"": ""cliente_activo_cfo_champion"", ""schema"": ""ia360_memory_fact.v1"", ""source"": ""andres-cartera-saldos-2026-06-06-0953-cdmx-rerun:cartera_cobranza_portal"", ""persona"": ""Cliente activo"", ""objection"": """", ""confidence"": 0.88, ""preference"": ""Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default."", ""missing_metric"": ""Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual."", ""recurring_pain"": ""Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas."", ""affected_process"": ""cartera_cobranza_portal""}",2026-06-06 02:06:12.043698+00,2026-06-06 15:53:17.348232+00,2026-06-06 16:37:08.880445+00 +3,ia360_memory_fact.v1,af92a9e986ffa524c90d3fcae8836e6b,6,whatsapp,5213321594582,5213321060293,544,6a233922ec5fcb4d0,Camiones Selectos,Camiones Selectos,Cliente activo,cliente_activo_cfo_champion,"Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.","","Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.",taller_garantia_dias_detencion,"Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.",0.940,pending_owner_review,pending_owner_review,2,"{""role"": ""cliente_activo_cfo_champion"", ""schema"": ""ia360_memory_fact.v1"", ""source"": ""andres-cartera-saldos-2026-06-06-0953-cdmx-rerun:taller_garantia_dias_detencion"", ""persona"": ""Cliente activo"", ""objection"": """", ""confidence"": 0.9, ""preference"": ""Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default."", ""missing_metric"": ""Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado."", ""recurring_pain"": ""Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación."", ""affected_process"": ""taller_garantia_dias_detencion""}",2026-06-06 02:06:12.064607+00,2026-06-06 15:27:05.352266+00,2026-06-06 16:37:08.880445+00 +17,ia360_memory_fact.v1,542a4f1a96138b7c66fcc35adab64088,,owner_context_load,5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo_owner_multinegocio,"Tono directo, concreto y orientado a impacto con datos; vive en su celular (mobile-first); 3+ años de confianza con Alek; NO prometer de más (frustración previa: 9 meses de POS sin resultados).","Disposición a invertir baja-media (""agua al cuello"") aunque reconoce la necesidad; urgencia y visión altas.","Empresario multi-negocio de Ocotlán, Jalisco: Amaretos Pizza (7 sucursales, ~120 empleados, 85% del revenue), compra-venta de equipo industrial usado (15%), Summit Pizza (100% suya, laboratorio de innovación) y expansión a Aguascalientes.",gestion_portafolio_multinegocio,"",0.950,owner_approved,active,1,"{""note"": ""cargado presencialmente con Alek 2026-06-09"", ""source"": ""data-sheet EmmanuelOrozco 2026-03-14 + harvest 2026-04-17""}",2026-06-09 23:27:37.101949+00,2026-06-09 23:43:34.722347+00,2026-06-09 23:27:37.101949+00 +19,ia360_memory_fact.v1,7e4b543856cde9b0bc361d24f91d9646,,owner_context_load,5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo_owner_multinegocio,"","",Retención de clientes es su problema estructural: 62.6% de los clientes compran una sola vez y no vuelven; 65 clientes frecuentes en riesgo hoy (22-45 días sin pedir); el teléfono de 10 dígitos es la llave de cliente (no hay CRM formal).,retencion_clientes_wamo,"",0.950,owner_approved,active,1,"{""source"": ""analisis retencion Zapotlanejo oct2025-abr2026"", ""recurrentes"": 313, ""inactivos_30d"": 997, ""clientes_unicos"": 1247}",2026-06-09 23:27:37.101949+00,2026-06-09 23:43:30.722347+00,2026-06-09 23:27:37.101949+00 +21,ia360_memory_fact.v1,30a895ec469505670a220b1e5413da6c,,owner_context_load,5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo_owner_multinegocio,"","","Datos operativos reales (Zapotlanejo): pico de ventas 19:45-20:15, valle 17:00-18:30 (oportunidad promocional); especialidad #1 Pepperoni, combo favorito Hawaiana+Pepperoni; food cost 22.93% (saludable); descuentos sin política clara (un cajero concentra el 66%).",operacion_sucursales,"",0.920,owner_approved,active,1,"{""source"": ""dashboard-amaretos-v2 + analisis 2026-04-17"", ""ticket_promedio"": ""$242"", ""ventas_zapotlanejo_6m"": ""$1.03M MXN""}",2026-06-09 23:27:37.101949+00,2026-06-09 23:43:29.722347+00,2026-06-09 23:27:37.101949+00 +20,ia360_memory_fact.v1,c7e4125ae54626a8567dc607cf27cd8c,,owner_context_load,5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo_owner_multinegocio,"NO volver a pedirle información que Alek ya tiene (lo remarcó dos veces en la conversación); no plantearle el bot como canalizador a humanos, lo rechazó explícitamente.","","SOLICITUD ACTIVA (9-jun-2026): vendedor automático por WhatsApp, spec confirmada por Emmanuel: responde preguntas frecuentes, apoya a clientes y nuevos clientes, toma el pedido y le da seguimiento, y hace upselling. El menú es el MISMO en todas las sucursales; las zonas de cobertura CAMBIAN por sucursal (Alek ya las tiene); el número de WhatsApp lo entregará al final, cuando el modelo de prueba esté validado.",canal_whatsapp_ventas_pedidos,Validar con Alek: zonas de cobertura por sucursal (y colonias con costo extra o sin servicio); 3-5 upsells prioritarios con sus reglas; precios reales del menú (el borrador tiene precios estimados).,0.970,owner_approved,active,2,"{""source"": ""chat_history ids 948-970 (2026-06-09)"", ""conexion_wamo"": ""el bot vendedor puede reactivar a los 997 inactivos y 65 en riesgo""}",2026-06-09 23:27:37.101949+00,2026-06-09 23:43:33.722347+00,2026-06-09 23:43:33.722347+00 +18,ia360_memory_fact.v1,f912b644e024fb870e88753e75459047,,owner_context_load,5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo_owner_multinegocio,"Presentarle avances con números reales de sus propios sistemas, no estimaciones.","",Proyecto vivo de transformación digital con Alek (TransformIA) desde feb-2026: score OG4 1.57/5 (caótico); sistema de analytics CAPTA conectado a su POS Sofroso/FileMaker vía ODBC; dashboard v2 con food cost ya entregado en su Drive (17-abr-2026); 10 de 24 ítems CAPTA listos.,analytics_capta_dashboard,"Metas de venta por sucursal y período; Excel de costos; fechas de ferias locales (Jamay, Ocotlán, Atotonilco, Tepatitlán, Arandas); encender la máquina de La Barca para desbloquear comparativas año contra año.",0.950,owner_approved,active,1,"{""source"": ""harvest-sesion-77 + handover sesion 78 (2026-04-17)""}",2026-06-09 23:27:37.101949+00,2026-06-09 23:43:28.722347+00,2026-06-09 23:27:37.101949+00 +36,ia360_memory_fact.v1,f918b322bfbbb42d4e7d1cd17447fac5,,owner_context_load,5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo_owner_multinegocio,"","","Las 7 sucursales de Amareto's Pizza (todas en Jalisco): Ocotlán (matriz/HQ), Zapotlanejo, La Barca, Jamay, Atotonilco el Alto, Tepatitlán de Morelos y Arandas. Solo Zapotlanejo tiene el POS conectado en línea; La Barca (con histórico desde 2023) tiene la máquina apagada.",operacion_sucursales,"",0.950,owner_approved,active,1,"{""nota"": ""listado abr-2026; el data-sheet de mar-2026 listaba otras plazas, prevalece el calendario operativo"", ""source"": ""calendario-sucursales.json (2026-04-17)""}",2026-06-09 23:43:33.722347+00,2026-06-09 23:43:32.722347+00,2026-06-09 23:43:33.722347+00 +37,ia360_memory_fact.v1,bc9b327c5266553a5001ef91c8a0ecf3,,owner_context_load,5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo_owner_multinegocio,NUNCA cotizar precios como definitivos: el borrador de menú tiene precios mayormente estimados sin validar por Emmanuel.,"","Menú Amareto's (borrador 14-mar en poder de Alek): especialidades confirmadas Amareto's Especial, Hawaiana, Pepperoni, Mexicana y BBQ Chicken en 4 tamaños (personal, mediana, grande, familiar); complementos confirmados Amareto's Wings y breadsticks; refrescos; Combo Individual y Combo Familiar; tiempo objetivo de entrega 15-20 min; canales actuales: sucursal, WhatsApp, teléfono, Uber Eats, Rappi y Didi Food.",menu_catalogo,Precios reales por tamaño y producto; promociones activas; productos de temporada; diferencias con el menú de Summit Pizza.,0.900,owner_approved,active,1,"{""source"": ""knowledge/menu-amaretos-borrador.txt (2026-03-14)""}",2026-06-09 23:43:33.722347+00,2026-06-09 23:43:31.722347+00,2026-06-09 23:43:33.722347+00 +34,1,ad1e677ea560c72a4ca3a1ee961bcc8a,,chat_history 2026-06-09 17:12-17:33 (conversacion en vivo),5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,Dueño,"Bot vendedor por WhatsApp: atender prospectos Y clientes (ambos), responder FAQ, cotizar, TOMAR PEDIDOS y vender, dar seguimiento al pedido y sugerir upselling (bebida/postre/extra/combo) segun eleccion, horario, sucursal e historial. NO canalizar a humanos. Ya existe catalogo de precios, menu y metodos de pago. El canal de WhatsApp para esto AUN NO EXISTE (numero nuevo por habilitar; primero se prueba el modelo sin tocar el numero final).",,El canal de venta por WhatsApp no existe; todo se atiende manual,ventas-whatsapp,zonas de cobertura por sucursal y reglas si una colonia no entra o lleva costo extra; 3-5 upsells prioritarios y reglas si/no,0.900,pending,active,1,"{""source"": ""conversacion 2026-06-09"", ""detalle"": ""alcance del bot vendedor definido por Emmanuel en chat en vivo""}",2026-06-09 23:43:49.218171+00,2026-06-09 23:43:30.722347+00,2026-06-09 23:43:49.218171+00 +38,ia360_memory_fact.v1,d50019027bdaf908e46c1bac103371a0,,owner_context_load,5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,cliente_activo_owner_multinegocio,"Al proponer mejoras, anclarse en estas capacidades YA construidas (no vender desde cero): el bot vendedor puede nacer conectado a la base de retención.","","CAPACIDADES YA CONSTRUIDAS para Emmanuel (feb-may 2026): ETL directo a su POS Sofroso/FileMaker por ODBC multi-sucursal con cache local (funciona sin VPN); parser del campo libre de clientes que extrae nombre, teléfono y dirección; motor de retención que clasifica a sus 1,247 clientes por teléfono en 6 estados (68 activos, 65 en riesgo, 87 dormidos, 86 perdidos, 780 de única compra, 161 nuevos) con cohortes mensuales ajustadas por turistas/locales, gap promedio entre pedidos y frecuencia por cliente, más listas top-30 accionables por segmento; análisis de food cost por sucursal (Zapotlanejo 22.93%) y dashboard autónomo ya entregado en su Drive.",analytics_capta_dashboard,"",0.950,owner_approved,active,1,"{""source"": ""calcular-retencion.py + retencion_clientes.json (2026-04-17, logica 2026-05-12) + harvest sesion 77/78""}",2026-06-09 23:53:05.938489+00,2026-06-09 23:53:05.938489+00,2026-06-09 23:53:05.938489+00 +40,1,d78aefbed7f9722435e0a66bbe86a314,,"imagenes menu WhatsApp ids 995-996 (2026-06-09, leidas por vision)",5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,Dueño,"MENU AMARETOS (vigente, imagenes 9-jun-2026): TAMAÑOS pizza: Chica $139 (4 reb), Mediana $192 (6 reb), Grande $249, Familiar $284. ESPECIALIDADES: Pepperoni, Al Pastor, Hawaiana, Suprema, Especial, Canibal, Vegetariana, Mexicana; o ""A tu manera"" eligiendo 1-4 ingredientes (pepperoni, jamon, salchicha, chorizo, salami, pastor, salchicha italiana, atun, tocino, pina, morron, jalapeno, aceituna, champinon, jitomate, cebolla).",,,ventas-whatsapp,,0.950,pending,active,1,"{""tipo"": ""menu_precios"", ""source"": ""vision sobre chat_history 995-996""}",2026-06-09 23:56:51.054822+00,2026-06-09 23:56:51.054822+00,2026-06-09 23:56:51.054822+00 +42,1,3ab89549966df9f8c20eed5e7da9f356,,"imagenes menu WhatsApp ids 995-996 (2026-06-09, leidas por vision)",5213321594582,5213339499453,908,,Amaretos Pizza,EmmanuelOrozco-2026,Cliente activo,Dueño,"COMPLEMENTOS (upsell natural): Boneless $129, Papa-Bites $49, Papas a la francesa $49, Refresco 600ml $30 / 1.75L $49. PROMOCIONES: Paquete 1 $255 (mediana especialidad o hasta 4 ingr + refresco 2L + papas), Paquete 2 $309 (grande + refresco 2L + papas), Paquete 3 $345 (familiar + refresco 2L + papas; ingredientes base no incluyen pastor ni queso); Mediana Pepperoni $139 SOLO mostrador; MARTES: 2 medianas $299 (cualquier especialidad o hasta 4 ingredientes).",,,ventas-whatsapp,,0.950,pending,active,1,"{""tipo"": ""complementos_promos"", ""source"": ""vision sobre chat_history 995-996""}",2026-06-09 23:56:51.057031+00,2026-06-09 23:56:51.057031+00,2026-06-09 23:56:51.057031+00 +44,ia360_memory_fact.v1,qa:5219990000803:recurring_pain:gd,,whatsapp,5213321594582,5219990000803,,,,,,,,,Reportes manuales le comen horas cada semana (QA G-D),,,0.650,approved,approved,1,{},2026-06-10 19:23:25.425795+00,2026-06-10 19:23:25.425795+00,2026-06-10 19:23:25.425795+00 diff --git a/.cleanup-backup-20260610/deal5_backup.csv b/.cleanup-backup-20260610/deal5_backup.csv new file mode 100644 index 0000000..8c73850 --- /dev/null +++ b/.cleanup-backup-20260610/deal5_backup.csv @@ -0,0 +1,178 @@ +id,pipeline_id,stage_id,title,value,currency,status,assigned_user_id,contact_wa_number,contact_number,contact_name,expected_close_date,notes,position,won_at,lost_at,created_by,created_at,updated_at +5,2,13,IA360 · Alejandro Orozco Flores · Pre-call (Flow),0.00,MXN,open,1,5213321594582,5213322638033,Alejandro Orozco Flores,,"[2026-06-02T22:24:21.925Z] 100M flow: Quiero el mapa → Diagnóstico enviado +[2026-06-02T22:24:30.398Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-02T22:24:38.902Z] Preferencia de día seleccionada: Hoy; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-02T22:37:55.406Z] Reunión confirmada (recovery). Calendar event b39f2mth7ma0ea2m0e1f4rk6fc; Zoom 86573547533; inicio 2026-06-03T16:00:00.000Z. +[2026-06-03T01:21:16.147Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-03T01:21:22.641Z] 100M flow: Seguimiento ventas → Dolor calificado +[2026-06-03T01:21:27.974Z] 100M flow: WhatsApp → CRM → Propuesta / siguiente paso +[2026-06-03T01:21:32.687Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-03T01:21:36.807Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-03T01:21:43.605Z] Preferencia de día seleccionada: Esta semana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-03T01:21:58.334Z] Reunión confirmada. Calendar event: rnuib89hgvn6lvtnpagrc0517k; Zoom meeting: 89753098594; inicio: 2026-06-04T20:00:00.000Z +[2026-06-03T01:47:11.255Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-03T01:47:17.955Z] 100M flow: Captura manual → Dolor calificado +[2026-06-03T01:47:53.904Z] 100M flow: Agente follow-up → Propuesta / siguiente paso +[2026-06-03T01:47:57.991Z] Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom +[2026-06-03T01:48:03.094Z] Preferencia de día seleccionada: Esta semana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-03T01:48:14.239Z] Reunión confirmada. Calendar event: 7ebaqd2ndutl07shu9klrgt39s; Zoom meeting: 88524154774; inicio: 2026-06-04T23:00:00.000Z +[2026-06-03T01:48:48.640Z] 100M flow: Seguimiento ventas → Dolor calificado +[2026-06-03T01:48:50.071Z] 100M flow: Agente follow-up → Propuesta / siguiente paso +[2026-06-03T01:48:54.636Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-03T01:48:59.192Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-03T01:49:47.419Z] Preferencia de día seleccionada: Hoy; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-03T01:50:11.072Z] Solicitó agenda por texto libre (¿El viernes por la mañana?); fecha interpretada 2026-06-05; se consulta Calendar real +[2026-06-03T01:50:56.491Z] Solicitó agenda por texto libre (El próximo lunes por favor); fecha interpretada 2026-06-05; se consulta Calendar real +[2026-06-03T18:47:37.542Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-03T18:48:21.253Z] 100M flow: Ver ejemplo → Propuesta / siguiente paso +[2026-06-03T18:54:41.265Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-03T18:54:47.150Z] 100M flow: Seguimiento ventas → Dolor calificado +[2026-06-03T18:54:52.930Z] 100M flow: WhatsApp → CRM → Propuesta / siguiente paso +[2026-06-03T19:28:44.494Z] Dolor por texto libre: Por favor dime que no le dejaste fijo las palabras flow x/x (área: ventas) +[2026-06-03T19:51:46.521Z] Dolor por texto libre: Mira, no me llegó nada +[2026-06-03T19:53:42.599Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-03T19:54:28.408Z] diagnostic_answered: ventas / esta_semana +[2026-06-03T19:54:39.015Z] 100M flow: Primero el mapa → Diagnóstico enviado +[2026-06-03T19:54:46.927Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-03T19:54:57.810Z] Preferencia de día seleccionada: Mañana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-03T19:55:09.041Z] Solicitó agenda por texto libre (El lunes); fecha interpretada 2026-06-05; se consulta Calendar real +[2026-06-03T19:55:37.532Z] Dolor por texto libre: Cuando si hay? +[2026-06-03T20:34:01.416Z] Solicitó agenda por texto libre (Que opciones hay la siguiente semana?); fecha interpretada 2026-06-06; se consulta Calendar real +[2026-06-03T20:39:20.451Z] Solicitó agenda por texto libre (Que días de la próxima semana?); fecha interpretada 2026-06-06; se consulta Calendar real +[2026-06-03T20:39:37.682Z] Reunión confirmada. Calendar event: n1smr1j121bc85tcu8v1krggq4; Zoom meeting: 83689523277; inicio: 2026-06-06T18:00:00.000Z +[2026-06-03T22:25:22.168Z] Solicitó agenda por texto libre (necesito una reunión para mañana"" — o — ""opciones la siguiente semana); fecha interpretada 2026-06-04; se consulta Calendar real +[2026-06-03T22:26:49.457Z] Solicitó agenda por texto libre (y otra cita para el miercoles de la siguiente semana); fecha interpretada 2026-06-10; se consulta Calendar real +[2026-06-03T22:26:52.074Z] Reunión confirmada. Calendar event: auqlqltf5cqs168fa5hd0dpq0g; Zoom meeting: 84289927932; inicio: 2026-06-04T23:00:00.000Z +[2026-06-03T22:27:10.033Z] Reunión confirmada. Calendar event: 4sgjfe40o6tloob858dveh5vlk; Zoom meeting: 83384387213; inicio: 2026-06-10T19:00:00.000Z +[2026-06-03T22:57:23.427Z] Solicitó agenda por texto libre (necesito una reunión para mañana); fecha interpretada ; se consulta Calendar real +[2026-06-03T22:57:50.059Z] Reunión confirmada. Calendar event: dthqvi0bjf43l1d9iqmrj8set8; Zoom meeting: 86599063900; inicio: 2026-06-09T16:00:00.000Z +[2026-06-03T23:14:13.067Z] Solicitó agenda por texto libre (y otra para el miércoles de la siguiente semana); fecha interpretada 2026-06-12; se consulta Calendar real +[2026-06-03T23:14:47.088Z] Reunión confirmada. Calendar event: 0pnnq7ejpjnl792hrh4p6lrn14; Zoom meeting: 83972299789; inicio: 2026-06-12T20:00:00.000Z +[2026-06-04T00:18:12.941Z] Solicitó agenda por texto libre (necesito una para mañana); fecha interpretada 2026-06-04; se consulta Calendar real +[2026-06-04T00:19:32.101Z] Reunión confirmada. Calendar event: 67r6ifjtlri9faclgq38s3one4; Zoom meeting: 85049741697; inicio: 2026-06-09T17:00:00.000Z +[2026-06-04T00:36:10.102Z] Reunión cancelada (aprobada por Alek). Event 67r6ifjtlri9faclgq38s3one4. Sin reuniones activas → vuelve a Requiere Alek. +[2026-06-04T00:36:41.588Z] Solicitó agenda por texto libre (agenda una reunion para el viernes y otra para el siguiente jueves); fecha interpretada 2026-06-05; se consulta Calendar real +[2026-06-04T00:36:53.986Z] Reunión confirmada. Calendar event: 9mke9q3sofbdcavo6bu8jjg2gk; Zoom meeting: 89391541105; inicio: 2026-06-05T17:00:00.000Z +[2026-06-04T00:38:58.866Z] Solicitó agenda por texto libre (agenda una sesion para el proximo jueves de la proxima semana); fecha interpretada 2026-06-11; se consulta Calendar real +[2026-06-04T00:39:14.903Z] Reunión confirmada. Calendar event: k6q54vhnqge9bbv0gjoce62ad0; Zoom meeting: 89342884641; inicio: 2026-06-11T22:00:00.000Z +[2026-06-04T00:43:58.161Z] Reunión cancelada (aprobada por Alek). Event 9mke9q3sofbdcavo6bu8jjg2gk. Sin reuniones activas → vuelve a Requiere Alek. +[2026-06-04T01:49:57.829Z] Dolor por texto libre: ventas y prospeccion de empresarios de alto nivel (área: ventas) +[2026-06-04T01:50:16.098Z] Dolor por texto libre: pues la prospeccion sobre todo (área: ventas) +[2026-06-04T01:50:44.477Z] Dolor por texto libre: ninguno (área: ventas) +[2026-06-04T01:51:00.297Z] Dolor por texto libre: pues me caen referencias por BNI (área: ventas) +[2026-06-04T01:51:28.943Z] Dolor por texto libre: no, mas bien como automatizar la obtencion de prospectos de alto nivel y automatizar la conversion en frio (área: prospección) +[2026-06-04T01:51:44.179Z] Dolor por texto libre: que ninguna saaabeee (área: prospección) +[2026-06-04T01:52:05.318Z] Dolor por texto libre: empresarios de alto nivel, y quiero saber como obtenerlos (área: prospección) +[2026-06-04T01:52:18.503Z] Dolor por texto libre: ninguno saaabeeee (área: prospectos de alto nivel) +[2026-06-04T01:52:33.817Z] Solicitó agenda por texto libre (si por favor); fecha interpretada sin fecha → próximos días; se consulta Calendar real +[2026-06-04T01:52:54.523Z] Reunión confirmada. Calendar event: hgaehtv9l66urie2ff2hnbfops; Zoom meeting: 89255453923; inicio: 2026-06-09T19:00:00.000Z +[2026-06-04T02:03:06.938Z] 100M flow: Más adelante → Nutrición +[2026-06-04T02:03:31.760Z] nurture_selected: interes_especifico +[2026-06-04T02:03:39.028Z] 100M flow: Ver ejemplo → Propuesta / siguiente paso +[2026-06-04T02:03:46.340Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-04T02:03:53.239Z] Preferencia de día seleccionada: Esta semana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-04T02:04:12.421Z] Reunión confirmada. Calendar event: v2ttluo7o3b6jeh0e09uqk2ai4; Zoom meeting: 83163603280; inicio: 2026-06-05T23:00:00.000Z +[2026-06-04T02:05:06.349Z] pre_call_intake_submitted: Geek studio Developers / CDO +[2026-06-04T02:05:11.338Z] 100M flow: Sí, agendar → Requiere Alek +[2026-06-04T02:05:18.091Z] Preferencia de día seleccionada: Esta semana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-04T02:05:47.796Z] Reunión confirmada. Calendar event: sr3qmh14589d7k2e7480hbgmd4; Zoom meeting: 89702175897; inicio: 2026-06-05T22:00:00.000Z +[2026-06-04T02:08:36.069Z] Input: Diagnóstico; intención inicial detectada +[2026-06-04T02:08:45.938Z] Área de dolor seleccionada: ERP / CRM +[2026-06-04T02:22:49.240Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-04T02:23:25.787Z] offer_router_answered: menos_5m / mas_1m → Premium +[2026-06-04T02:23:34.093Z] Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom +[2026-06-04T02:26:45.771Z] Preferencia de día seleccionada: Mañana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-04T02:27:01.536Z] Reunión confirmada. Calendar event: coaiacflo3hift38uqgghiikv8; Zoom meeting: 85678542949; inicio: 2026-06-04T22:00:00.000Z +[2026-06-04T02:27:38.540Z] pre_call_intake_submitted: TransformIA / CDO (con reunión ya agendada) +[2026-06-04T02:30:15.873Z] 100M flow: Más adelante → Nutrición +[2026-06-04T02:30:36.265Z] nurture_selected: contactar_mas_adelante +[2026-06-05T00:46:47.610Z] Dolor por texto libre: que puedes hacer por mi? +[2026-06-05T00:47:09.002Z] Dolor por texto libre: que tools tienes? +[2026-06-05T02:01:02.645Z] Dolor por texto libre: Quiero automatizar la prospeccion (área: ventas y CRM por WhatsApp) +[2026-06-05T02:01:57.474Z] Dolor por texto libre: No, hoy lo llevo por bni y manual pero quiero automatizarlo para obtener leads de alto nivel en automatico (área: ventas y seguimiento) +[2026-06-05T02:02:11.508Z] Dolor por texto libre: 1 por mes (área: ventas y CRM por WhatsApp) +[2026-06-05T02:02:30.573Z] Dolor por texto libre: Todo lo hago yo, soy solopreneur (área: seguimiento de prospectos) +[2026-06-05T02:02:48.897Z] Dolor por texto libre: Obtener los de alto perfil (área: ventas y CRM) +[2026-06-05T02:03:23.646Z] Dolor por texto libre: Empresarios de empresas exitosas y los que no pertenecen sólitos se van al ver el precio (área: ventas y seguimiento) +[2026-06-05T02:03:41.340Z] Dolor por texto libre: Por el tamaño de la empresa (área: ventas y CRM) +[2026-06-05T02:04:00.593Z] Dolor por texto libre: Urgencia que demuestra en la adopcion de ia (área: estrategia y gobierno de IA) +[2026-06-05T14:15:57.686Z] Dolor por texto libre: vienen +[2026-06-05T14:16:10.595Z] Dolor por texto libre: Conseguir clientes nuevos (área: ventas y CRM por WhatsApp) +[2026-06-05T14:16:23.169Z] Dolor por texto libre: whatsapp (área: ventas y CRM por WhatsApp) +[2026-06-05T14:16:35.654Z] Dolor por texto libre: solos (área: ventas y CRM por WhatsApp) +[2026-06-05T14:16:47.592Z] Dolor por texto libre: seguimiento (área: seguimiento) +[2026-06-05T14:17:02.670Z] Dolor por texto libre: todo yo (área: seguimiento de leads por WhatsApp) +[2026-06-05T14:17:15.546Z] Dolor por texto libre: 1 por mes (área: seguimiento) +[2026-06-05T14:17:36.837Z] Dolor por texto libre: ya te dije que todso yo mporque mne preguntas lo mismo (área: seguimiento) +[2026-06-05T14:18:49.017Z] Solicitó agenda por texto libre (si); fecha interpretada sin fecha → próximos días; se consulta Calendar real +[2026-06-05T18:27:05.763Z] Dolor por texto libre: Conseguir clientes nuevos (área: ventas y CRM por WhatsApp) +[2026-06-05T18:27:23.785Z] Solicitó agenda por texto libre (whatsapp); fecha interpretada sin fecha → próximos días; se consulta Calendar real +[2026-06-05T18:27:45.739Z] Solicitó agenda por texto libre (solos); fecha interpretada sin fecha → próximos días; se consulta Calendar real +[2026-06-05T18:27:56.003Z] Dolor por texto libre: seguimiento (área: seguimiento) +[2026-06-05T18:28:07.491Z] Dolor por texto libre: todo yo (área: seguimiento por WhatsApp) +[2026-06-05T22:38:03.766Z] Alek eligió pipeline persona_beta, pero aún no está cableado para envío automático. +[2026-06-06T14:54:52.802Z] Dolor por texto libre: Y que sabes de mi? +[2026-06-08T15:08:40.168Z] Solicitó agenda por texto libre (cuándo podemos agendar?); fecha interpretada sin fecha → próximos días; se consulta Calendar real +[2026-06-08T15:09:08.490Z] Reunión confirmada. Calendar event: pcbl5l7d9lsrcp8ougnk49s9j4; Zoom meeting: 89169226195; inicio: 2026-06-10T16:00:00.000Z +[2026-06-08T15:09:39.230Z] pre_call_intake_submitted: Geek studio / Cdo (con reunión ya agendada) +[2026-06-08T17:38:35.179Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-08T17:40:08.225Z] diagnostic_answered: ventas / esta_semana +[2026-06-08T17:40:23.737Z] 100M flow: Sí, agendar → Requiere Alek +[2026-06-08T17:40:29.532Z] Preferencia de día seleccionada: Esta semana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-08T17:40:44.573Z] Reunión confirmada. Calendar event: enjn59poo4ki7f060fq9um52n0; Zoom meeting: 89212798854; inicio: 2026-06-10T17:00:00.000Z +[2026-06-08T21:49:37.171Z] Reunión confirmada. Calendar event: r11rv3ajrbkjt1pmei6781ep30; Zoom meeting: 87042400174; inicio: 2026-06-11T16:00:00.000Z +[2026-06-08T21:57:03.018Z] Dolor por texto libre: whatsapp +[2026-06-08T21:57:11.578Z] Dolor por texto libre: tengo problemas con el seguimiento de ventas (área: operacion_cliente) +[2026-06-08T22:15:59.036Z] Reunión confirmada. Calendar event: mimu4riho282uds03adb8dp0lk; Zoom meeting: 89662379329; inicio: 2026-06-10T19:00:00.000Z +[2026-06-08T22:16:31.391Z] pre_call_intake_submitted: Geek Studio / CDO (con reunión ya agendada) +[2026-06-09T00:31:36.996Z] Reunión confirmada. Calendar event: q2ka0gakk9g34b8mrbt5vrgamo; Zoom meeting: 86520922242; inicio: 2026-06-12T19:00:00.000Z +[2026-06-09T19:32:08.318Z] Reunión confirmada. Calendar event: uv950935pdc9540mm58h2t9bg4; Zoom meeting: 81685035029; inicio: 2026-06-10T22:00:00.000Z +[2026-06-10T14:38:38.114Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-10T14:38:59.796Z] diagnostic_answered: ventas / esta_semana +[2026-06-10T14:41:12.841Z] 100M flow: Captura manual → Dolor calificado +[2026-06-10T14:41:23.744Z] 100M flow: Agente follow-up → Propuesta / siguiente paso +[2026-06-10T14:41:29.570Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-10T14:41:48.193Z] offer_router_answered: menos_5m / menos_50k → Starter +[2026-06-10T14:44:03.443Z] 100M flow: Agente follow-up → Propuesta / siguiente paso +[2026-06-10T14:44:08.382Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-10T14:44:26.363Z] offer_router_answered: menos_5m / menos_50k → Starter +[2026-06-10T14:46:06.183Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-10T14:48:21.927Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-10T14:48:28.451Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-10T14:48:35.504Z] Solicitó detalle: Alcance +[2026-06-10T14:48:43.680Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-10T14:48:48.545Z] Solicitó detalle: Costo +[2026-06-10T14:48:55.986Z] Solicitó detalle: Alcance +[2026-06-10T15:00:09.712Z] 100M flow: Captura manual → Dolor calificado +[2026-06-10T15:00:19.175Z] 100M flow: ERP → BI → Propuesta / siguiente paso +[2026-06-10T15:00:23.167Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-10T15:00:27.660Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-10T15:00:41.054Z] Preferencia de día seleccionada: Hoy; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-10T15:00:48.630Z] 100M flow: Ver ejemplo → Propuesta / siguiente paso +[2026-06-10T15:02:00.981Z] 100M flow: WhatsApp → CRM → Propuesta / siguiente paso +[2026-06-10T15:02:06.921Z] 100M flow: Ver ejemplo → Propuesta / siguiente paso +[2026-06-10T15:02:18.322Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-10T15:02:25.443Z] 100M flow: Estoy explorando → Nutrición +[2026-06-10T15:02:30.688Z] 100M flow: ERP → BI → Propuesta / siguiente paso +[2026-06-10T15:02:34.603Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-10T15:02:41.095Z] 100M flow: No prioritario → Nutrición +[2026-06-10T15:02:49.571Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-10T15:02:54.824Z] Solicitó detalle: Costo +[2026-06-10T15:03:03.422Z] Solicitó llamada; falta crear evento real en calendario/Zoom +[2026-06-10T15:03:30.625Z] pre_call_intake_submitted: Geek studio / Cdo (con reunión ya agendada) +[2026-06-10T15:09:32.132Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-10T15:09:47.711Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-10T15:09:53.455Z] Preferencia de día seleccionada: Esta semana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-10T15:23:41.375Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-10T15:24:00.337Z] diagnostic_answered: ventas / este_mes +[2026-06-10T15:24:20.084Z] 100M flow: Primero el mapa → Diagnóstico enviado +[2026-06-10T15:24:25.667Z] 100M flow: Estoy explorando → Nutrición +[2026-06-10T15:24:31.507Z] 100M flow: WhatsApp → CRM → Propuesta / siguiente paso +[2026-06-10T15:24:37.124Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-10T15:24:45.351Z] 100M flow: Estoy explorando → Nutrición +[2026-06-10T15:30:46.620Z] Área de dolor seleccionada: Gobierno IA +[2026-06-10T15:30:53.021Z] Solicitó diagnóstico ligero de 5 preguntas +[2026-06-10T15:31:00.576Z] Pregunta 1/5: trabajo manual o doble captura en ERP +[2026-06-10T15:31:06.964Z] Solicitó detalle: Arquitectura +[2026-06-10T15:31:11.636Z] Solicitó llamada; falta crear evento real en calendario/Zoom +[2026-06-10T15:31:31.845Z] pre_call_intake_submitted: Geek Studio / CDO (con reunión ya agendada)",0,,,1,2026-06-02 22:24:21.938573+00,2026-06-10 15:31:31.846662+00 diff --git a/.cleanup-backup-20260610/owner_contact_backup.csv b/.cleanup-backup-20260610/owner_contact_backup.csv new file mode 100644 index 0000000..7648945 --- /dev/null +++ b/.cleanup-backup-20260610/owner_contact_backup.csv @@ -0,0 +1,2 @@ +id,wa_number,contact_number,name,created_at,updated_at,tags,custom_fields,assigned_user_id,profile_name +2,5213321594582,5213322638033,Alejandro Orozco Flores,2026-06-01 19:41:55.147517+00,2026-06-10 20:08:08.377111+00,"[""campana-ia360"", ""cancelada-aprobada"", ""decisor:decisor_final"", ""detalle-solicitado"", ""diagnostico-enviado"", ""diagnostico-ia360"", ""dolor-captura-manual"", ""dolor-seguimiento-ventas"", ""dolor:ventas"", ""ejemplo-solicitado"", ""explorando"", ""hot-lead"", ""ia360-vcard"", ""intencion-detectada"", ""interes-erp-integraciones"", ""interes-gobierno-ia"", ""interes-og4-diagnostico"", ""llamada-solicitada"", ""mapa-30-60-90-solicitado"", ""mecanismo-agentic-followup"", ""mecanismo-erp-bi"", ""mecanismo-whatsapp-crm"", ""no-prioritario"", ""nutricion-suave"", ""oferta:Premium"", ""oferta:Starter"", ""owner-intake"", ""owner-pipe-guardar"", ""owner-pipe-pendiente"", ""owner-pipe-persona_beta"", ""pipeline:revenue-os"", ""pre-call-ia360"", ""preferencia:contactar_mas_adelante"", ""preferencia:interes_especifico"", ""problema-reconocido"", ""prospecting-100m"", ""requiere-alek"", ""respondio-diagnostico"", ""reunion-confirmada"", ""reunion-solicitada"", ""revenue-os-calificado"", ""revenue-os-diseno-propuesto"", ""revenue-os-handoff-agenda"", ""revenue-os-interesado"", ""staged"", ""texto-libre-ia"", ""urgencia:esta_semana"", ""urgencia:este_mes"", ""zoom-creado""]","{""email"": null, ""stage"": ""Capturado / Por rutear"", ""staged"": true, ""espo_id"": ""6a222ddec21f79bf7"", ""ia360_rol"": ""CDO"", ""area_dolor"": ""Gobierno IA"", ""ia360_fuga"": ""doble_captura"", ""captured_at"": ""2026-06-05T22:37:53.960Z"", ""captured_by"": ""owner-whatsapp"", ""ia360_dolor"": ""ventas"", ""vcard_wa_id"": ""5213322638033"", ""owner_action"": ""solo_guardar"", ""referido_por"": ""5213322638033"", ""campana_ia360"": ""IA360 100M WhatsApp prospecting"", ""fuente_origen"": ""whatsapp-template-100m"", ""ia360_empresa"": ""Geek Studio"", ""ia360_sistema"": ""otro"", ""intake_source"": ""b29-vcard-whatsapp"", ""area_operacion"": ""operación cliente"", ""ia360_bookings"": [{""start"": ""2026-06-12T19:00:00.000Z"", ""zoom_id"": 86520922242, ""event_id"": ""q2ka0gakk9g34b8mrbt5vrgamo""}, {""start"": ""2026-06-10T22:00:00.000Z"", ""zoom_id"": 81685035029, ""event_id"": ""uv950935pdc9540mm58h2t9bg4""}], ""ia360_objetivo"": ""Tesitng"", ""ia360_sistemas"": ""Todos"", ""ia360_urgencia"": ""este_mes"", ""dolor_principal"": ""Trabajo manual / doble captura en ERP"", ""ia360_resultado"": ""Automatizar la prospección de empresarios de alto nivel"", ""owner_action_at"": ""2026-06-05T22:42:30.872Z"", ""vcard_phone_raw"": ""+52 1 33 2263 8033"", ""proximo_followup"": ""Alek debe proponer llamada y objetivo"", ""ia360_preferencia"": ""contactar_mas_adelante"", ""pipeline_sugerido"": ""guardar"", ""source_message_id"": ""wamid.HBgNNTIxMzMyMjYzODAzMxUCABIYFDNFQjA0MTQ5NUE3QURCMzRENTlGAA=="", ""ultimo_cta_enviado"": ""apply_call_terminal"", ""ia360_booking_start"": ""2026-06-10T22:00:00.000Z"", ""ia360_revenue_canal"": ""excel"", ""ia360_revenue_dolor"": ""Hoy se confian a la memoria y un Excel, se nos pierden como 20 leads al mes"", ""ia360_revenue_state"": ""nutricion"", ""servicio_recomendado"": ""Diagnóstico IA360"", ""ia360_booking_zoom_id"": 81685035029, ""ia360_oferta_sugerida"": ""Starter"", ""ia360_revenue_volumen"": ""20 leads"", ""ia360_booking_event_id"": ""uv950935pdc9540mm58h2t9bg4"", ""ia360_ultima_respuesta"": ""Llamada"", ""ia360_revenue_started_at"": ""2026-06-09T00:29:41.004Z"", ""ia360_revenue_calificacion_raw"": ""Hoy se confian a la memoria y un Excel, se nos pierden como 20 leads al mes"", ""ia360_owner_number_vcard_blocked_at"": ""2026-06-06T00:27:49.778Z"", ""ia360_owner_number_vcard_blocked_name"": ""QA Owner Number Block"", ""ia360_owner_number_vcard_blocked_reason"": ""shared_contact_phone_matches_owner"", ""ia360_owner_number_vcard_blocked_source_message_id"": ""wamid.qa.ownerblock.1780705669702""}",,Alek diff --git a/.cleanup-backup-20260610/owner_meeting_links_backup.csv b/.cleanup-backup-20260610/owner_meeting_links_backup.csv new file mode 100644 index 0000000..c1574af --- /dev/null +++ b/.cleanup-backup-20260610/owner_meeting_links_backup.csv @@ -0,0 +1,4 @@ +token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,created_at,expires_at,reminder_1d_sent_at,reminder_sameday_am_sent_at,reminder_1h_sent_at,reminder_starting_sent_at +KEQE-kCbWjJ2ajvNUGZm5F8c,q2ka0gakk9g34b8mrbt5vrgamo,5213322638033,cal,2026-06-12 19:00:00+00,2026-06-12 20:00:00+00,Reunion con Alek (TransformIA),https://us06web.zoom.us/j/86520922242?pwd=aHDg0eR32lEfoNGCBPOPXPxIIwJQOQ.1,2026-06-09 00:31:37.000899+00,2026-06-14 07:00:00+00,,,, +n0AzZqbgc9GkyFFqliXVgRML,uv950935pdc9540mm58h2t9bg4,5213322638033,cal,2026-06-10 22:00:00+00,2026-06-10 23:00:00+00,Reunion con Alek (TransformIA),https://us06web.zoom.us/j/81685035029?pwd=aeH4oq9elzeIf8L0OAhLsxtzmrIu9F.1,2026-06-09 19:32:08.325384+00,2026-06-12 10:00:00+00,2026-06-09 19:35:32.932568+00,2026-06-10 14:00:11.42997+00,, +jWSlWnqG1kXTpWvNsNf926ln,r11rv3ajrbkjt1pmei6781ep30,5213322638033,cal,2026-06-11 16:00:00+00,2026-06-11 17:00:00+00,Reunion con Alek (TransformIA),https://us06web.zoom.us/j/87042400174?pwd=bVdlext7aPvuVumcjMhnSaHbmS7Xex.1,2026-06-08 21:49:37.177451+00,2026-06-13 04:00:00+00,2026-06-10 15:03:23.135121+00,,, diff --git a/.forgechat-credentials b/.forgechat-credentials new file mode 100644 index 0000000..16710ff --- /dev/null +++ b/.forgechat-credentials @@ -0,0 +1,6 @@ +ForgeChat POC credentials +URL local: http://127.0.0.1:4017 +Admin email: admin@forgechat.local +Admin password: y3YNoh5lEQHVcPoD/5kk3XBP +Meta webhook verify token: 7419999ee702323c2d73e917826df514 +Created: 2026-06-01T16:43:27+00:00 diff --git a/DEPLOYMENT-WA-GEEKSTUDIO.md b/DEPLOYMENT-WA-GEEKSTUDIO.md new file mode 100644 index 0000000..f0b2990 --- /dev/null +++ b/DEPLOYMENT-WA-GEEKSTUDIO.md @@ -0,0 +1,69 @@ +# ForgeChat POC — wa.geekstudio.dev + +Fecha: 2026-06-01 + +## URL pública + +- https://wa.geekstudio.dev + +## Ruta de despliegue + +- Repo/compose: `/home/alek/stack/forgechat-poc` +- Caddy global: `/home/alek/stack/caddy/Caddyfile` +- Cloudflare Tunnel config: `/etc/cloudflared/config.yml` + +## Servicios + +Docker Compose en `/home/alek/stack/forgechat-poc`: + +- `forgecrm-db` — PostgreSQL 15 +- `redis` / container `forgecrm-redis` — Redis +- `forgecrm-backend` — Node backend en puerto interno `3011` +- `forgecrm-frontend` — Nginx frontend, publicado localmente en `127.0.0.1:4017` + +Ingress: + +- Cloudflare DNS/tunnel: `wa.geekstudio.dev` +- cloudflared ingress: `wa.geekstudio.dev` → `http://localhost:8094` +- Caddy local listener `:8094` → `127.0.0.1:4017` +- ForgeChat frontend `/api/*` proxy interno → backend `forgecrm-backend:3011` + +## Configuración ajustada + +- `backend/.env`: `CORS_ORIGIN=https://wa.geekstudio.dev` +- `backend/.env`: `NODE_ENV=production` + +Credenciales admin locales guardadas en: + +- `/home/alek/stack/forgechat-poc/.forgechat-credentials` + +No exponer ese archivo en chats ni commits. + +## Verificación ejecutada + +- `https://wa.geekstudio.dev/` respondió `HTTP/2 200` vía Cloudflare/Caddy. +- HTML público contiene: `ForgeChat — Inbox & CRM for WhatsApp Business`. +- Login admin por API respondió `HTTP 200`. +- `/api/auth/me` con cookie respondió `HTTP 200`. +- Sesión admin expone páginas visibles: + - `home` + - `chats` + - `contacts` + - `pipelines` + - `bulk-message` + - `template-builder` + - `chatbot-builder` + - `media-library` + - `admin-settings:whatsapp-accounts` + - `admin-settings:users` + +## Pendiente para Meta WhatsApp Cloud API real + +1. Crear/configurar Meta App y WhatsApp Business Account. +2. Configurar callback público: + - `https://wa.geekstudio.dev/api/webhook/whatsapp` +3. Usar el verify token configurado en `META_WEBHOOK_VERIFY_TOKEN`. +4. Cargar/guardar `META_ACCESS_TOKEN` y datos de cuenta/número desde la UI o env según flujo de ForgeChat. +5. Probar handshake webhook de Meta. +6. Probar envío/recepción real con número de prueba antes de producción. +7. Evaluar licencia Sustainable Use License antes de adopción productiva/comercial amplia. diff --git a/assets/ia360-bca-template-candidates/contact-sheet.jpg b/assets/ia360-bca-template-candidates/contact-sheet.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8fd50529329c3c2e03f5ad75905d240a6862ff58 GIT binary patch literal 257636 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®!5)i$9(b5dzn|d{wovn|8H=mh{b@5kgkY&H{ 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 z%0D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

AlHUgJ7#Z>!vX?ml6IR*1op>jJ->? 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 z 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 literal 0 HcmV?d00001 diff --git a/assets/ia360-bca-template-candidates/optimized/dolor_ceo.jpg b/assets/ia360-bca-template-candidates/optimized/dolor_ceo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0d08652760ddd16eca82d48837f7048ba633231a GIT binary patch literal 181293 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%0DrY zNod)$V56VZj)rQ@hTGLf+hyF$Wn)IluevGJQwXa##=@Q>aLF9Fc%1;r1#$;?nReHs<;Ysvfp}a_0ayIjtm2Knh6sv<> z?ji6!*vvUG03%~NaelFr)@=$!WWmi#7kuTKwdn>o3G04|WaGF*>nw%{`%9|G9fkHB z5j&wFOAg2}E!*oB5Nw;rc*GQP95L`il;3fWTsmd@kJmsqJy<9W7~()E!=j=Y_i0h< z-KZ<9M>Vo2swsz9p`pO}2%BZan3Uu%fXnM27juYKh%Gu9hN6hid;b8j#&KDzPwrT< zxAiOC4uusC^UrJ+tu%cQ`|21M&LRNb*j(F-faG`fnkGfKLEewt0#~9S?i5OQ755xK z51;h_ys>jp19NZG%a?M(%UGgQy}~S8IjLymucHR{8)i@k7QtGnqk6=>dAr@qnJs(j z7z+g=_SH)7+AN(HFHdy5G1Y31ZwszmJ&eAPyjbEL1F;;_+wNHvj-PRePz-g%t~p={ zbKmJt1(D`ozBkg&*P9Y_y<}EDY4#npfQpg2w@jv zWUD9PEwOulRwWp{o`lEIy^j!Idg-+6cy-!Zau60)o`u~+2g;a*fWgs#r?q)qV2U@z zSs(T>#WNOQ<8X-s2&!Fb;f98sM#u?1h+>x&$9PObMX~c8y{d~~h7!8C%xLm>n|;LE zX89%5dvO8@{w3LfP@_x@0jYxuaq-@Bd{Vd6?Fjf+c5!ZbA%5BiHx$8>E&V0x5mtO@ z{{Un0R}eqpHC8UuGlJZ9A&xzW@94|a7hq!y1>tpauD(K6DT4Py2Mv8je6)AbZ2Ola z4AFvQce-eQW?Z#fo3V0 zh_JdbQT22!uCNndI=Y~JrF@1l1zPAKa9Ma*>OZ+`;9ir*c$D%$F$OTNTr&nQ+ixwbx=13K|%`lIiC$9G}Kp)s$}L4Oh3z6*`QX zd^=7|tbXJ$+*Y~%Aa8a0n8dp4K%3JUJjS;zKra0yW?X*Q8_d=RCOXTZy*!708H0Ph zi{bm5+E)RgA8VnR_Y=)M_tm^dXHDR42MM z7{XRq!j{0h!4QCNP^4)2ST)CLUKsWUChkjCB^b2|fU20`(E1_&03igfWsTS3yz>6h z-EQj;!@o0=mJfK^=qVDqw@dee=<1DGXP7&8^qm;E4hZlwZaty}+!OkjU~s*=5{Etx zF~19q0Whia4-NkS!I%!AiFpL+5_b1}B_lraqjSl&-e3#A3$Q+1lv3 zB1GA7bAa4*sm*=M&}*R(nHkJ97&e#5IF5<8XNmkiuw4

de_#ClTaQQZ_hSa_29 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

5vs771oAg?qxRTPxK(-fEl?Dwauj-)*OAgp@my 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

AlHUgJ7#Z>!vX?ml6IR*1op>jJ->? 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 z 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 literal 0 HcmV?d00001 diff --git a/frontend/public/ia360-bca/dolor_ceo.jpg b/frontend/public/ia360-bca/dolor_ceo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0d08652760ddd16eca82d48837f7048ba633231a GIT binary patch literal 181293 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%0DrY zNod)$V56VZj)rQ@hTGLf+hyF$Wn)IluevGJQwXa##=@Q>aLF9Fc%1;r1#$;?nReHs<;Ysvfp}a_0ayIjtm2Knh6sv<> z?ji6!*vvUG03%~NaelFr)@=$!WWmi#7kuTKwdn>o3G04|WaGF*>nw%{`%9|G9fkHB z5j&wFOAg2}E!*oB5Nw;rc*GQP95L`il;3fWTsmd@kJmsqJy<9W7~()E!=j=Y_i0h< z-KZ<9M>Vo2swsz9p`pO}2%BZan3Uu%fXnM27juYKh%Gu9hN6hid;b8j#&KDzPwrT< zxAiOC4uusC^UrJ+tu%cQ`|21M&LRNb*j(F-faG`fnkGfKLEewt0#~9S?i5OQ755xK z51;h_ys>jp19NZG%a?M(%UGgQy}~S8IjLymucHR{8)i@k7QtGnqk6=>dAr@qnJs(j z7z+g=_SH)7+AN(HFHdy5G1Y31ZwszmJ&eAPyjbEL1F;;_+wNHvj-PRePz-g%t~p={ zbKmJt1(D`ozBkg&*P9Y_y<}EDY4#npfQpg2w@jv zWUD9PEwOulRwWp{o`lEIy^j!Idg-+6cy-!Zau60)o`u~+2g;a*fWgs#r?q)qV2U@z zSs(T>#WNOQ<8X-s2&!Fb;f98sM#u?1h+>x&$9PObMX~c8y{d~~h7!8C%xLm>n|;LE zX89%5dvO8@{w3LfP@_x@0jYxuaq-@Bd{Vd6?Fjf+c5!ZbA%5BiHx$8>E&V0x5mtO@ z{{Un0R}eqpHC8UuGlJZ9A&xzW@94|a7hq!y1>tpauD(K6DT4Py2Mv8je6)AbZ2Ola z4AFvQce-eQW?Z#fo3V0 zh_JdbQT22!uCNndI=Y~JrF@1l1zPAKa9Ma*>OZ+`;9ir*c$D%$F$OTNTr&nQ+ixwbx=13K|%`lIiC$9G}Kp)s$}L4Oh3z6*`QX zd^=7|tbXJ$+*Y~%Aa8a0n8dp4K%3JUJjS;zKra0yW?X*Q8_d=RCOXTZy*!708H0Ph zi{bm5+E)RgA8VnR_Y=)M_tm^dXHDR42MM z7{XRq!j{0h!4QCNP^4)2ST)CLUKsWUChkjCB^b2|fU20`(E1_&03igfWsTS3yz>6h z-EQj;!@o0=mJfK^=qVDqw@dee=<1DGXP7&8^qm;E4hZlwZaty}+!OkjU~s*=5{Etx zF~19q0Whia4-NkS!I%!AiFpL+5_b1}B_lraqjSl&-e3#A3$Q+1lv3 zB1GA7bAa4*sm*=M&}*R(nHkJ97&e#5IF5<8XNmkiuw4

de_#ClTaQQZ_hSa_29 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

5vs771oAg?qxRTPxK(-fEl?Dwauj-)*OAgp@my zf4SN1?~oBX6Rx^RNP4LvwU_BG8_KVTUy8sHK&nKo7+J%BzE;vr?2z&l(Z?-U4d?se z6bWZXh;F#S+tojGWzQQI%~sZe{OU$nw26j|Ub{j{Kd3k7h_oJrZ>D zu&&8|u0Jb>M@1pDg7Wr>r&j2urNGTPPITd2G>1A9C{aFtN^peM$1kCa^xd8QW+H0+ z_wl=}xwDpU4BvYRPTY0_;W^gRmRxcP3pmcT3`jRNHBV<9-yQ^g39ZLZ#x>cq)!WX` z=%wt1g@gxig`frxoa=E>zCT)f5?E%3|Lyb6^28lvk+D(KGqVhWy?0aGV3)8HoC)j; zdd`Cuj2pdCzEQXpcW7~EOOEb~;y{VV^v=;k4SJLy!(3=DVRYl}NfZ^azJ4oKo+y?(`J-dv~P=-otWBQYFk;+uqz%^)O6&Jfz)6{bOp_U;@X!8tOf zV5H}re^{>H*hs|0BH5?+-iZq+Ll2C?CERYKNdx%Ag>;Jq9KrqPLX?l>KmGme@Pi&1 zUzN0;6vatQUF2L+j;vgKy6H_{;V1AsTg{4y--yrL?iMX-n}7@$`J>|0z$0wc{9LvEW#r^}8&(G*bIhbhIn;f4`gtxk5`0lt@ zjM6H{5D^i=%)%Ezvg}NfzGq0jM3afbmN^qq@sm+NghpjkGaLDAX}JKP;Q9wj>2yC8 zz0V`|Y|tQpWZ4X6*UNP10wa*<1i};RR6tv+NE08cD$j)R7#x5+TbezS5VokNHo?Zz zx!fYa+9yMJR`9AoN^@E9Gsy}B&P1?wS5PEIxmIvS)zI3TiIablwv|w6GwHPJjnmBibqx7u&CF$iXcEsn) zu6(Oayv#pP^j))1p~_!;E=aP?ec2-DYBJ=pc%fGK>kURLwK=*3NU%0Z_2HlR3 zpu$kC48eIe%KQOWscIVpYWmdu8+qnbS%CqYtD(^CJy~dNbsi|$6-8VnxZPx!lPAWL zG_NgrF}^LB5d~54E_MVzYnbfP||rK(st1L?S-}$mok#+iDL3p zmv$UcK~@l~*ac?tj!diM`}}aR5>rxBN=~j6B30hy9~kyr3&Y zzbI>jE6`O^R;9lhv!HB?Uj)k#X3E1k#hO=H9VW>%S>#91_hSe6?X;>CK*aYVg~0M~ zFgi(}YRrp1$~o}^0?t>B2*DjcW>VVF#_|F;TJm4%ygw;+EBS&(vOBrI2&1Pknja4M z;ontfJxAbaW4bgMvsU(UUn?7}9!Z8>uAqd_EtPSQpD=*LT&pnSB`K7hde=vD%x~l^ z2w|c(nLb&VojebpI**sQ%UwgS27Rml^qvgc3|+FExXI~_R(o?DUB1Q%2M+05!d-u# zx-8X;55N+>4Wje|_|`=2(rpZGrO}f6IQ#)Z?|kkh{$FbfkcN3`Ff~uAohkp!r+dyli)4!=nLsGT4D}EB0sY2WZ&x50 z?xV+w0@0R^Q?o%EMqIxW-Q^~*TVH+H@iXPI3!Q@32p20gNHE?}GQ>xDf_MjtCr56k z?u6cc^qn;i?v@S{(i?#fh(!_Td_GW=mOIYMY=veQe2xX^FpI2OR-pF!b9B`sBV*yP z5aW;~gxFw1?oblXOzHK@%3Jx)IlM6j;~2KStMJb zkWyuK8RUPCi!e}u3}u$>rm#oxK5M#>UR35hrr8iB-W=8b2>e=R-}KD)N4rpc?$ao%HA{9^e}sqls&()*q!`4 zMqwmtpQ#fi_z7#6R{3b>ud$|5(68-n3Xd~=`>&3{v&lO|sEPHVa0B+sU+Q%9@O&yS zvBL+(!mqQxEk|2=76-$^(|UH93@zbJb!$mSTDzI;pSXl92dabKlP6x{=cqK<>xwi5 zS5lk>_Po7(eP@eN!*ezzeNR_gX?~dWs6hACZpX-R+K9_>>3C=km%u{P59QXL3&JSi zfDlDdm|a&!;}Zt`x=0JcLaJTz3pz&t#e4{E2+pTQXc#kO z3R`d!&=H4oV0qxS&)1ug@I26>!6XY~MQ5_RaxrUlN`lnV5exY=nG9PP#xDt!Hu68I z8DeuB#kM74+}Ua-%_Cqe{%p?N1TtKBbG?$xgChe_y=mg$?!KB;i$hU7Gs#2QGF&hkuD^Y=#I+{5{Hls zQXiVkv}y)_(j!CcgN+ZF{!096ocF4HtkU_$LiubD;5QZq%O58k8At6=1McSSAE<=f zNO_vEixQreg&`V_?0~@FF`APF!{2J?C&!T2?WSBpbT@=$byFkvFj6owiM!Uw*hE$S zc4pfeUBOlt_*lr4)X^8rJ8$n;Ql8m6s$1V9BiPY~L|*D&s5%a2=iAcPVT{^I>bY7m z6LaKZKh6kHaNUoU=9zfEQ-PnV7uHfaXI*?(lq2nHssf0j@BJQMPY`eHuH8onn|pm_ zWSO-d#SE&SmLtZ+L zX2DsIT17@{X8eww!i}A)O(y9`9CqT?w;`S&Nx5{uYQAFj|LN{6YlysPd9Y<>ev!mt=xW*^t2|U z|IN-h`v8;-)U)J;QGf-{4d&TO<(~czmw;OynV`yq)n4&m4q;> z%8lq(utsu`C0HY&u^beUUs|qK1_&M~>E#q>Ns}Z`rlehIQNGiI%C?MkleBC3LZ(bZ zebQrrX9-h~Zt!BZXorjCn~I(Fns!G%n(ED(Evy$7-TWk+BXB%Ht)lkH~c0rp-H z3q#c#PkEHi9@h93fNU~AOMOHqNCQH7K6JY;XFt(UUs=JR42}S5ECoawOkfyKNg5r3 z;bW=)(OAVGGxZOD+ZEzLZX$|p=OS}k3vYd&ia-#6uRt_x$XP{lMLk;4FR{vCklm6Z(R*+oPhKVws>e46}= zLBbkX){pS%I4ywxDq+l;@SUz=uY2vvXDw>DpnUkJ0 zy&nDQb)}9Ewi_>HeUyv4m^switRp+6t>G<-#zKHJrM~%R|1n*{^cta_EQ7V zm`TK?`UfWW+3HPn!u8$4dc>jE6ux@ZYYaMVq9@40f%j2o;ptz>d^GhmW$?l!Z%KEM zbpAj+LjFJv8NuF?&pMJOlT^LY_ZKZ|RwLbaF;{l>qs)HMu8KRA4WrC-n1ZRZwIDA! z(zXM%LFgPp1Rx5l#=bLp{dH^aEPtz2L07oM<2rBZF2atzXP7B}c;TO;@9$DFm!_*O9_rz% z8X8I!t5)%jaBxw#hV`WomS)a{u)JWMQ>A*9&a?kO2{{AhN_^n{>QVMc<9FEB-Mv2e zkX_?pESr5*hnc?3!hi~&^Zt9kdNT+8`u0=N8GMa z`o9T*W#S)YdOaL}$DH^{AGdaXgDHP3D5+I>rBiyfM*lQvanFFK_clY$(7Q31P?WIH zwtrKCL{Q(duVXy-R9G8@AAXpQGzo&;D3z(cPqXSH+yPJy}g@qRhy@XYD?#O#`Fr zw*A?wZ>poW6Epd~*E%|h-MOQzb0##|^L6!|D5D0|`;iB+R6+*Ckn=S_Kv;t2rZSzh zwp~g#PO!p-sPqHlhTQe=r|8Y9DHO)+*H}X%G4U&g>u3#9SGq0bY86ww^Ofw>yeJP4 zIHxLORw0H`H#kNdU}eF|+7zv$ScQf!Wp>3?NbeFo@pWRR_8>L7bV$U2k?_Eb1}t*r z@0jELq3z*UTb7HftDmodY_wmP_`O9E+pGq-WsexSh{jJBIH2gf>`ZImkwf`4GCRDs zSW`o?SKguQF9le65MT5Sm?ziONg^7XrJT^&@CMc`X&ubxFAs=IudrUJ#*3Xq-;yXx z5Dq;I9rG)Uj6oV2*FK_|g1L$gC-U%y?YA+YHW+&F799 zfB1L+<1K=a%xJEJen)wdwYg<#(B@on*%eY;R|!mvJ=~*vi_j%|hlqMC`T}6XVi7Y7 zkCWHv{&V6m=^I8|**{QJf1rS0s+KqS`y5QCX9ebR^lEdR&!6|39D&g8H4 znOH-DBj^!6dt(dYm_=?WFoEB>P+51D5E8#RAbRyR*NxJ6D_^7L`%#ImT~4*FD>XE z>ug5e^N`#C&=vF^XTWlupz4F+v+A;rO(pYN9Z5Ub9m873jltA5xMYiVsrou!y^dOc z`f+Bi?E&6BSxE-iX)!%dPx_||{2_sbn56)r|{ly6JoBXC4GY8f=Dc+Rqu^t0B6hG_HhYtc6DEcPzz#*uM$!Vi(`(JqSn zqE(9B5>pKE`=i%F)XHN>1!2NUL;MykB9^R=U zDN`-p?)+u7Coqj8DaglV`XLs+iWwM@6+)g())|Em@B|C8W4$$*22v<1nmWXo6vGpY z?RgDr6DE!kP_nUfx)NTX)+{ECJ7KxLxc}5(6S0t!^0b;{@e2*X}rlvfi^c5^2v{5Em4g2`` zNY5e`jp(jvoRR}f*<1a6WyddH_VL0!-IM$dQ76|UQ|}^x0UB1}Vo;KlMSaP^56NDi ze#Key)X(2P`d);V$g|LX`m+C{e|Y-^#uaLRoyoqiA|$>W&c1je^#!r2gMhz((5lHO)#*(W zd!gK^V-cxD__s?d0V>u9!n0#QKMZ%(3OaQF9S7$OL*8Ec75%+OOT)FP(poV88|RMa z@UdEfE#l&NDaAw+%*W9goG(o09)sCol6QEx zOH#)cS5|vD=v04XNQV3eS~dO|2UyJ{#8w7;I@eMuk{+v*{vLx(S5%DvO|nMxh@gfI zQ^-VaiTED;8mKE=go5i)7-+#t*)tR^oqNaX1--w3_!`rSkM+;u6&hLh3a&dc^D} z4Vf>FYC~u?6$3|1N)e;a@pQNHr2YjIiC#P+zllEDql;A_AyxChQjT0$}T;EHu8!|q4MQ-YeH@; zk|(;Z0bG~yLC;nB;#(&bg9-1}O>fW0SGxj7Bu{w$5M+Zyi7zqY9CuUi%>uXJ+k4Bs z+~pqkTmwuMJ2Cq!GQ2%wc@?7087eX6#m$2U$6uY-+XqX*zp4qplwP$%wnztEUgi4} z+}tm`rVX#Zw8@m;k#1UbLsKPP_s?YMk|4bZ1XI23n-E`wB^TzDQimNFTP04=iSQPX zryS$f#O40|^>NCD=8WdnCe&S;5ci{7NG4fV9*Id?H(RkwzVl-D zS^KDSp)f{UH_NGw$NnzsYw}6KXdENWy$AgTO;E>+#OD)5y z#Ldsgyun%IxBA5Z1l;LD{gBg#O4;|ibD#vWaOzeKtRVL8)?H>5q&3*+jgE19RoMc% zLjCTy-K4T;xhzz$SpRf zIo;f(Wj~Uyx@q8Ed>D~nFdy0pv3=4|tx+JEVIorFc9n0KBZV8HC!1wDX;|rZ_N!Rs zjEDVM<3WmaJ<8&W9ec&)AJ-j!>c1#JD>GVYg%g9wM#Z;X!sNTP>VIB6(s2}=dW(ok zo`Qd>CBbY@yIXyehEI?}|K0bOSvHS^HMM$rEg2IL#x~dOpvSDjIrT@XH`%<-A9ZPR zypzIaKc7X7cl?$R`99;B6^g=h$s_ru!~J!x+vSd4L>e^7WaeAdw-FMVF{PMme&)KV zo(o#u$qCD=M9Tf~T1iYq$n)O)g>=4|Pwx8z?s}SDHRqdV`|>3!G#vQpUr%atgG?9` z4hII`*jNlGXt8KQ@QqPT_fQ645q%(ePyxziQ5; zBqRj|77uAe+eg{ZxtO`QQ2GQ*V9y2=${;$qRZq%R_1WAq|0WPvAGmuQDCvFF;NV~ z1KtbHN9=uq@!s?kwmHbkw4GeBt2V_Y^!50)ZYNCtjD4pUrtmbvYMn0>jll37cX|E0 zr}HD(Rtz-pidRGCV>kJ8y_hDiwlKdc1sR<02gef|c}qsXsZb;nlZU+7Ao^@ckqRmVqa zPGcbl%56%hRD$g@`G`k%<)59aNyO!2P>2~*>hCHlTZB5)3VPn}<_xJ=e@Im?!X7+m z;E^Ry*VDCuDb`h6Q9E>EkRaE}TrInkKCWzX795!C*_JL(d8SidDyl%?z%LDQ?qg(b zm>M*Spk1{qpP=E-OqHOt0~T!)}z}HRBUwjT>ETN z8Fv!H=rF0QtokYJ&3a8W*Uu$l<4#9T2w~6uEXE<_<;SNk#kUrXuBwii*`<^T zBq_e=howi&{>riO2g{zfV-C_?#fz@%yz3)Y<>lqoEAW#@d9?{%SK^DGXMLHFcVes? zZsPCRAMN{(iLbh!`BBU}>tKXwz2Ev}5q)c)oqM__yQ-ydg>4vk;+|Z^XculgZE9AW zX2idhKwQH=3DS`p{uz*(XqL}La#?s{Y<2&U5`J&>J=?uDSs`Nvi({e)3&%3A z15q+#3exY9ljlPDw`ERcTB;_D{`=oL`3m43Z7Ej2x-=}jd-(dS_-f3YnjX=eX-UWj z0~%*{-=AUO-SgJ?&+e}MUgwEv{Y4VUd)v#~?uAW)9tX^QliLMq>LX2;F-4{%>+*^Q z5=`RS(N!nrZC-;&>|UOGVX@te#u42=P-RUsVcn>w9J_q^KNM^WLf^IX59dUCuHTKl zrb@!=EU`DI#y?WLOL%KNL@# zOzlzJ+Vo6CwZL!Sn$xuQTuh!U{@_l6rCt)g$aY4JK4Adj)oeYVedsuKc|w(5V1NHj z`9p(TnY_BX#%Rkc$-vgCs`YB59c`_}taSs?iK;_>)nFt^f4-9mmJoxdd9!0E6Asj? zhUNww{}x))xm(FS^NjNH(dr969q9bB?tJ7635dKz$fQd;pNxS08~FK~QW98LU%)w| zTbRg1Pyhn!aC37WEnV{qS;;q3l$STGr&qQR#pCp1CdNpX-#AZ2tqW!Rfii7lNAzzx zrQ4g%C=4aHF-K?`88i3wh%FnY>jt&6N7a`t&!lZ^!kdA4j^QzO7$E`kwTQ>zM$9wBRZDN)piR$} zhI|XofpcCtQ1dhRldX?`_@a;%>T9_O7Sv-Yb(y1~-I7{qz27z=W7k4*gcd#uCmUmU zxmZNcVO>uj@lnETB*c-A%~BI3Dg?vCqS%+0C_nsxax5w*@<1jz|I!)CP`#Q9zo}>| z(Ko<_pZLuFK3}jg?5I;wo}B=65r~ z``h8iP9YrPdKOrD;VrYf#Z!CuP?2QY6SxA7U4}CsLM-;oe|;GgMhTr(#+)9;SPW8_ z?*9g6ZeM(7 z7`J0=F>S5(fW_W3JJy^s`dPnoC3q8b#CpAF=^XWGHImF^jija#bDd{9Z33=VxR14K zB(DmS8g_8>x4S{+xOd1!W4+mKw1#Z?F=_2q2;cY*A6ml4*5QtsH1@qu^(cvVCkU6z zvN4zCne|q24UK-i(c{}p9{bVH;5Y>P(e;j7X(@|_l=4F{Naj`cqrow09g>2;_-j`F z5#DVaP3GPy|8*e3&3O4`qt;_ZB}O-UoO0Uu@v_Z#uTA$9zV*zAxJi2^^*Jt6J-7L9x2OMP5xcc{Om-tQo%ig~=0Eo#_a3x3vG_9Z2TD$L z*OAAy2Z+H*<`d<%h~) z3<{;`mgnb>nKnupB&RbTr3idgvRe+_bV8wA?AtxP@1@yon3c}Aa^#4mCZl?nGO;Lr z#_2sMo%41YEb%I8oi37H%XrLALdM)82YzEhIi-*2{K)Xq)o%!GNugl1-7)n?h$)>eco-}jAwvJVqQXqhdW7eQW zzmzee>l)4MP8QfM+L`Ey1i4iTKE(%=V3UA1neO%7u0`k8@P4~tCjptoW0KZ_WCIYT zH{Lm%nSXppYP-B5_#_4G^PWvWCsTySrOIkJ82FKmHe0=_Rx$%h`${iu^^VYwhcyQE zpGu3})6km&9Ih%yM}MG}cX(o%`H_FsAQLOwo7zrpxhgv^>uhzD4V%~G;B}FyG-`1( zPqLuUkfa2tuZVMjj+_%lts8I&FX>ynC4C^Ga+f6Uq3bLN7{R!$ivUZr@QkpnL;F|ol@=uXIyqM~KRUe0Y9%Sj#p4rlmTW5uq* zAgP{_NXU6ge1-VolWR9O8mxNWDe8vI_1uM3esOp4X?_y9i}Pv?PlDG*b>|?aV%V5@ zNZI%7p{)^hT?t9(q-)HNj=GXK){^RRTED9HP!b!qi{R@I)K)hSEy^Mih&9YSE``Ml z^0}PD3zDu$GQtf@&Qmn}t@vG1|wNTO^T+&g@su72suP5z%#swPGQ>K|)LcScb zi{l%Q$yu4#E2_rzr+_P}Uv)X zUO_RNV9}nFo%KrNJJ->&&c6GjWc9@ly;oZ^V1)*X% zk(i^rYcELJ;?bLebElC68r%{}a85}m<#>Z`np-_as=gp34e4X+9+r`X2jqWPrr)kt}zUrBDM@wom;OZ#G zI{k4ZLwA^G0r3X;qlGY=AYFAkH7kCEvJp;>lf1F67m=Lnh3mg^bUm{8?SO^(o(EK6 zYMT|0N1$NjqUBjaodYUA0L6eRXZ`Qk0p|fD8W8pIHQ+Y@B0#UjHLF8~EMnI<8w6vEbTAGNFe{RqpC}Q}qmy3MR1Ox;R zWzQ7;k$*EN#CzTvZJRps`~bGex1qOa*$%m`LP4~@rGR^Z{2z#$J$deMGdn7tJj6BI zW-f9DfD%YZR~5>hF?>g9Cts)_zJ{ZS*ukARgBz-uecT!>dx|h`7Rq@W62kh_aew&- z>Qhi3IUE&r032B~3Z2u5a6E#DbYZH^{Gk2e6~=LR90?Jcg@rNc^29TVk&B!NT7ZZM z93!sSCY5#Uv5%L%z>m;fwGVGY1caXhTzX!violUYS>q;tZk62LD?6q783v7J@e<*2 zXre7w!52;;O$3Mm1`kJ8>sE5>M-g$3akzcU6Z^S^E&$0`RJ0d(AKF}u7W^c1P*Cuv zT9q{0!c?a>PRz5HR|O|7byq#gZ&_m*{U49W;XaT<jV6yMpvtg_S7|EYNzxJxLI`~P_Q?s&ML zpnuW3)43CzF6!x`#p%69@15uo(FN!9E_$!&9HRFwq9qc&6Fp9e==ptopWpNBy?p$^ z?at25%zk!e-m}*v;TOy;bZGQHjNb8F;MO@!ol=6h|M-E=i+mXF%~nh=P}g$7Xpw^6 zDLVrudc%9oeO5iE{RZF}K6B(Zh$jUz&Tt#;#C*sbs_T@Wx`VDKpT7|Ygl}+OG{nO1 zxhht7-8tN8e(AMwXnQd`kyLWg@c@|ZKa5NII6%&Td76-AUn^WtRECE)e!rv<0R6*& z-1wCPv%J#=Awa1>J6C%&haV<)pw88h?bUQ?&(the{b%>S2+CggGg$;w7I8&+=;W*j zC5UBXk8oW%BckfEKmN--w)z(v7Q_m$V(m7u{~A~2LhU;~+}0Kf#Uo@2?26L-eblwY zZ5tpbin9aIG;~M3W)U8lTMP%mDP+3woLAx5KC%F*gAeDz5Z7|6r(n+=+80b|FVpV` z1J6A|lQX_lkOWSq*E}Rov}c-|s9)`;WwP?qsS*B9g?d3q{g()ckW%>$eiX#EOq85Z{2er(z z8c@9xB+z|9{I+2Ti$#1Z^tDDKryNf;-B8TA@ZPb4sj2!&Fd})~a^x55I_)s0Ik1?* z!v85a@PG-XBA|ZJ$=zjrK__xmNZ~m5nRUrI$j|Rz=7QLy!@9>wrgHA3r4ereD9<7B z?EAnE$e z73#3TBnGFW{W4f$K)8o&42P@Yanl!GMp7SQwSgb9rV0fG%^%n0n4D-RKnk5aLz1p?KUh1o+s@$o+sHqTVp*bn1xM@t)9uY200z&>81%mi$x7TkJ;{N`VBE z3VU2?-W$$J6SamJ0JuzAn}pl zXKlx`9N1;W+L2y67G{oXmwkqzwS6O$r4Y3xa(&wO|1gw8wP6M~a#vmod|bDT%CHpT zaA(;R=24%Aue>{$W*_N3w<3A-OUuwrcJn_2O%OGi?ej;Z@ia`Dgk%%4=_v562s`YM zshF3WBIIWCKfSl9toc)cBl+tV!q&f3xoGTvbYjk-U{5UANA>`DZ&D7br>Ease5he3 z@f#%>fV-r=SqjB)J*WIn+IYc@pS0dLzr0{9Qov-4B!6nUi{{HM#XCLmebAT;9wCT? z53|u zB2=-(y4taDJJA2^R5q!|cW)?*@Y{@YEy9@2^2Vr84lAhfe0dG~(mR9WYrjZ*ULvyP zE_``>kz#?}Z`?NzAm27~mlo@;8|p}s?i--(gRrf&+xD+?RaY`fwt=j6Ri#)CUy7aa z0imLR5=0VETMHcb-r8hysV>rYgO6`DJ~H`3t+Y3Cv}kU3pvDC0yV#)TuI%Oq+(d!DwA*AOo3SJe;SuxxqTnq-Fzr*%-eb-M3H90 z5eqXmH18~N@|F0SJdI@TLH~?o3rm+;wZ`@JCPH?+}2- z0DBE%e$M_TpPSSdX#xocGYc~N8Qa4Pwq0{`qjT#hst*Yl5f_?Y<1S+L6Wk;^j>8H(DWaQr3e#R#!P)-cF9~+xIFa>OYesJlR zlDXZs6NcY>%Gq1U)a~0sZI|MQM;COeNd+2N!VPCdNLb3Jh}O+asbs;~K{3F{gzAtT za(-JK`|$k<4U;j`tP3E^Eij+|8Gw{D)BwW+}`0`2aj9y zrEc;VmIkH>7m4boga69So~p`-Y*@b=*w}<_JD3+AAGlDXQb>qH#R6ci=Y!$Xvcv$X zbZ8)0-Lrp86(ak#NI2zSGH%BWSU3s_$`UF)^Pp32MX5$J>xt*#HFd{kY@M@TX0Ea2 zVp+lmXvQ3?QBT7On9@Q_Cc;bl9(P^nPEOT48HC01&Z0{q4Lf2vR`5U1%|1nL)7-!C z3`)zPYY3AjlQ$LysKF@=cDrZQ7Ja`-EQ)*>6#-FEH3dr@GN%6OjjxS4%RmUD0z=gG zyWaf$7@6ulrFe(tBuaWICr{S>O@LmPQYRylyxQl=`m^yL(m$trH^pM)s(H@57QiTs z=Az3VgG@q4Q)Bv}gLL-vWk3N%Mqpd-Y3J-vj`J>LUJ6)~knIIMgQ;p-+1f3o^;1v` z2gecqsc;RiZTo{U{`pSbS5$_`KD^&osXUCpk%(z{X~P%U0ji3LXWY;a9i)25;bVG_&sqgcuYRU3 zQ<>jOuMJTBJuEz8heMAf?%>r9f4}jGuyyUK%;mAae&(VKWAe^g5$855z&F>AX1Ma- zJ0Tv&*Y-(i{|%C8e^s#INy%|go>M$$0;^T`j-uM8AU9gyfOYDPpak<0Qc$WNQ(N^3 zZNGxhiwr+V>;S#2U_|qXL8&UKg!|b1o}I?BPWDdF(h?lFsr0cmX12?|8ieIZT^0$B zB~85#t8M&G3IP!(AuIi}@3I-Hkj!MWsIp*(oexjJJey*Of3!dw@J9W$j+$UPQA%wH zi8iU5q`Y}ZEYWaaFYW+H!PG!6Ff@AWVHYZTl&X>muPIFjQQobTv$K+(O{L>NKey9f z{r4OYL3*If4T5u+h!ne7Kmco}=0jNQ>>$9-K1Qxk&Id8VniBoKr5DOz6{?|uKg--9 z1lUx8q^bfF_SuRvm<1vp%~Ov60yf17U7}RWqsF7-;xwsLRpS=&N4qxCMwSWxVa$$B zU%qL?!u#x^f5yRAnou(W9I(#pdlKEyjVy6@*`@f>PdljT7w$C4Ch6{%F@<=I-83|W zG_aH-hAMTQ(St$ipxyv7=UpVanc~lnMK~Rt!!mGn%FtX`UL6pKLzk_jHGoZVX~z~P z(~)8pht4J-L!G$COzndi=tQiMnr%E^hKSiutfcc9=OAHMI++qbw5wNG>5X0wF>S&p z&#X^c586p3mawvb(Gvr2S7+YWUfN%gMHMT38qRsOmQ8jL@%dJp3ANp`g4el8M508C z^@Emb?#5-J<0cIE_sH|DXo!h)4QCIrkt{32c9#Zzj74b_fw=SjO(6Btu^S+jKR3Vl z*&Vnd8-Mn4twyDKRwm>-wv7yrj*CEVpP`2Gk0JEWT@QoKp6NBh;$>sTMf^eq(#X{PZuvn&-788D6sV(PNeR zp|Ps{_#f}qwpf)IsksI8!*^n`On(0`+#wq!M#<%RCW){-DvH zwV6$rCIc$4-)7~)BiM1O$$7BdNWX|yB;;S+R1JcDWBK<|6!ZXh2H{dZ+yCHOFbm>U z!>$e4bgT9Rk62CsSRmw8wsY5=ZDy}bh}_O5llrD-XU!SH#Cn{D@q0r^;jO>K(>}=$ z;0AK2gwjV&V<%h&&(r`kFlE^^w~{S0g_5B&kLHK7f%0Y{@kKpfz6@Pmh2U6=xRK#- zn`+DUFvRdo6-3z| ztQBI5Z&oubDaqdZ$L(%_W|92&Z_77-fIFCfjv1}^3_)<|&;j0b=%K0MDFAXG6Y1;TKAc9ZuOui2cicWR4o00%9Hc)>RA=e@;^y z=n0`i&&jGi#tg&ed3GE5taNR=W~1p$=PJBX&ZF&mO<0ZZAkxC~#y`CJDybl5xVND# zkO6mvsFwkdGgi7;R0AN~0hvfFcwrWsy=oZkOuu|8JT{YUxRkH7v!%4dGwTzcUa1C$ zG0X0`@`0|Ih>3_D)f%O0_3}o4@!ezKPAHvyXAwC6137 z30<-O#`01Fd{sIb-vF0HX3VcLbY>+Kz(ICaBx61qUN9OGW*%b z39bD=eaFAyLd`4Wg^k{3L4bF%t)w`jHyeq9gMG2{T#NQn>aexMH;*)tG6h6VI8V9C zh*C=FZI*Vru(V!8Py8+AX};O;oRbt|^;0vRy1)iY(V3!oEht_Kl5G0wuVx}L_Mv`; zA`FO*a(AHk513z3;`&Bdd0c2Wx>L()z{ zf;GJ_>6bb@2|Hui5c{G>?B-}^``A>zHup(F1rKgffjwktt`n4tg2^-c{&~F%?*&Cq zVa0}`R`EN^G@=G*T~H0`@@B>jF=b^IKINVC@~%1^|1hQ@R=PI`JzDM>)3qb%wf$lr z^kO)wv@1&OCTZq~0nzf;q>5QSHnk6+jHICa$&Nlm%V#IhUYxC`0V>Vb$f?x8X>6F^ z$H#?8HfgqqCwo06sWPscGxN5foKKm@&;UZ64{K9U+yx;?%%T~mb5wJ_NcO1fXII)f z;#VxgR{VU2Raci3#V!E$5;{lxuDXJJuw5{*VGGug&CQASl7 zL5X4$!H2r;q_}bXM_XO1w`9{nqhS(iv!Vuu}iLa+#{{#s7>mp`@-?|4gPXw{%m|VegmX^6GHT2O^OU!niHrSM~GX zsW(M7KR?!EA@=`-_SydoW-1x{am!iCGqmSYe}cC&D3Q#hOC801ZAgc@wRBfl<8%i;mh zoa^k;7s#LbAl0)+FZg{rl{VQav7%5SM5=D_V5vkmpM)yFX=F5*o{aK<4~UWMu4{4_ zBL~w+_95xd>JQ+k#bAqct|Y@|7i-nq*nZ7H_ zMg~x(ucoX)93rrF*BADilPGi)zAn6w+*kT!XH|kwKeGcwO$~9^7UG&)B389;Q2if2E60vL&LH!0TCZU* zb0HRk#g%=1nyI7dk!_#wons8f0JDgldCYNpckd@YAUD?psLs=8)&_$+F4m&8A1R?RMjYE%-> zP3?aA+QM&j*XP6+ff|g5x!aROD09uu+c^6)SS53;r9P%2FT-l!i|s^Y8h1M~u}0KnajFKWf)z2tL5cXt<&E%>?R*HyP@_ zB;pFb#En%9cB?l1-DR5v&z~}nwArQ#eFe0h%#d?5e5#3uh4w8d1wO0iJOqLd5NHU5wzog@Rhbz3v ztGCb9IR-WOn_{h~Jk z_;9*gQozT_<{6wSR6yypM_jQY+iPfww`w>gyuT3*A(*`ChmzW7S`d|5aZlzV_V^VI;nScw zN?UBUN906rj{8F(IEmA|+c9{fYxvz2s&S-(V_!DzhVt^yN9|-*Zk7KmhTQtt%gUvp zV(JPbRj>sQ0)~SX-^w6isP^xclp~4Wbwj8LPy!*Iaw41pK!Ygy;qv6fOparPt#SLg z=x5WW9`HiDD;nNW66lc-P{yRzcSqwgI1<0Wr*KMkPjLWgQdqb+q1=L8F_+Q&*5`qe z9{l3-T(EkN&P@rf@szs>%n7ZS8F*hv*Q=4AB34nn-Z2K__SLK>LZ4JD)T10BRB;s? zxW~`!={P^|%a7F|e{CrWnlT_ozsnAd;N@nHnW&_PWMics@Czm(%V{~W(mv)TV$B^m zm5VT(VkUtdT6>uhlUtI2htxvw$H<}Z;(0%fVK`RNXYqSV z7A6$AN7L;zNcWzy0}qy6x#la2nS-H11` zszA&_^{0MMDRz=6b7Vp|rA<`Jvy_{(fev4n?4F741H8VQO9s2R1cIbnt0CDsx{qoh zT{qPslqXqNmk=gy>O>bQXaZ@gjLQPCQ4BOajucBY{~NWDtane}fmwfSV!u2{Ns8f9 zuMbIS2bF|ZS$QuP|5a@Hb}3PWdEp1nPh?arGT7x$l}c)>N&4st^kPHuyK5InZ1YK{ z9`5N||EzPG{)eGI5& zZ0j{flqDOe;??>(B_Ym6<=G=8Tagr-YMVcX6KRl%=x%faW6rS?4H9B<@Ccj}`VdLf z;7LuAA_!L$C!Ge1R}|HI0O6(A^1@rJ#VIU?o#w?^ZV?R&2%-Gd(1}$Id(+8vJt0=7 zm)Re~1Vj1NeP=5|UnA*(TL>(^qe(5PHYH}ic=PwOvC~t;iiy7-<>HK)R3j;M8I5vs z4~Q~{P)+4T>4xG5LY=Xy} zk`vy@@qBcbtP)H$2&hj~I+$7>(3LTJ$uutomOr}~F*r^;snP3ZaR{Tq91JAwBNR`@ z;wE@=*6ePVBd$;FgP(RF9@J*%MJbpQ2|~n2 zmYMLs5~CV!(E&n#QAODTqa(Z`0uey&71Jd?wSwbAiNQW-`NRG)$M9vR$~aO)O#?%d{wolL9wlH}cE6Ydf%$`|zPSRGIkkoWs#jZiTw46eo*xl-MqDro0 z8dRGH6*!@%ou>NWjB?#ir|4ny+_O` z&aoXWN8!-eE%%G!rM0DgiXKjAt(!b<(aVYMpXP19hv?i+1YxXgQmdk2+OmZ?DbNFV zO_Pw;fRsPwX(@giDS~q;gUrO7Ez$hV9lG>cc&hLewUQksV&J(>YK|u12oc z;}a9szw`c#Sd0)TdnUl^+QtD(MyHj{ssR-JOR;`Oqrq4 z0*nialkePqNzyR5l4TY1l^@fQxkY^RicE3&&tHZR!=xsPt@ zGQEnByE;P1;`aWk8Dh@zB0C9WbXGZdezp`K9{tl}pGG!%c%_1qgnTi&bV%ThR20j6vE z%Ufkp!AG(NMBLo1BRE*?&L9^e1S-MiE4)qL_ z&qB+pgAJyjCNO7K3E(M05w0GVWDYizfjRXxpXg0Z=OiM}q(#5fE9@e^f^tEo57py{ z;0E9d*z%(#Te!l!aW(pcJ>vZAg5(3k#n1i%8mO?@7lJ+_rKN_nZm9{f9`?!f1(GHT zHufIt&zs)VOU+y%NPTUII6SPFbfJ8Sb|FBLgG z63k1cYX0#!ojztVh_LOkq?<&zio5O*XN!3H0) z8qOg}HIl}!6ai~W6I7XN&j^*b7}vU_PLp}m&Pg#&Bw7o?d3d!1)j6yR3NNA zoFh;UVj$ATKd9X;0(FebTHB6(-_yBLY!Hafcd%P)4hG-pp6>2D&{cOM_y^@>!Az0? z#Z#v*^zd+w$T`$LP*n^~b)&yc;TwO|vMHiWAuuKcY2@N0m)ya|CZl&WiJn#%VQ;wD zK;A(pS_l^zXYEZXVXa%#9;9_7mng#uaqm% z=;*;O&ct6#=eJwn`21TkS@p89aRv++>)$@lUEw!pX;s_5NX)-#w;u z%$~IPSZ*k?^AKr3EvX_lRjLFQQ`Wygww;C9iN`%B6qyTy8O2~m;H%+PlZI0F;H8@r zoV!LYQM1Sjs_Mn)E7wQJ*QQ6=S%H)Lb2+G9B}s}+CpgwpGUU@5f@mK&kO#+=6wV6K znno(I;2UxD&%s-;+%-A7F0>qcE;%pYGy6Q3I{})1Sp>p(!greW79zt4nNi#n1geu| z>2Y3m#zZ2`(q0b0EvYa0$e36|0w6d%j$S3WgJ1NM!5l1uF?CV}vzyP^Kla9M(R+@d zq3bI4^moJgfd?{HIIL?bv1ZhbdS{fXOFOU06mi8UN$j~$I?Y@2C zh|J}f{Gp!4_bkIz@Z#%-W>ZHShc6$gdd-yw1G=?!Ia5DGBzcs3&y~p#KTB%{;sWGK zR5aLj#XWLtD&`y3Z_y$a>n4{IV_iL?x5ZJv@?-?MhkhcXd>*T|Kfl{XwES5j}013j;G#(?a|_! z8$0O|4#%EU^yMP7_D*^*Rs$i1l(J0Cp>l;;>jEl0;RO3c)TOd-V5klWnLFkwk#~PY zzpA+a0XL(~61O5q-sTD>FYNQ^76cAi?1F1pX)~2XLs=2gBi8CqvNKK^8S_KxI^Rwf z+g@SzJQt)p3=EGztc6UVk=fh+?Z&l^dL0gkOqqm;IlM!rT7|N4&+uf6vXwO9f9^+>V zv>$=&s5f)CO}>WtR+(0+ZE;2``^Sc!F-P*y#H;+wn6L$h|2W~F4Xc@9s|a~G&-$e1 zd(E`TY|!W->2oZ{*+2w-G#avr0CmE{GwfoHmXJo){vT{ty)w)g7wk#UfTP3_6fcWR zOFZy)8LZO%@!s9*XahqtT8eCKq+bW@_Ga~r^YfJ7kZ3RyMC8$Ijg*!GlUax0Ad|Z2 ztA(blR=fzZcP#$>i^U29&V-9xc#~^gKd64q%N%$u$|CfX679ocwGEbM-y5EwEmjDw z#D?C<75&42w-n7HJjI{vH&Ffgj{RCLLtcRS)iOF3x{SB#B8QcJ*tmb1?idsQ-L@&> z1KpgD;-Bc*s=g|;nNC)=rsJj$LxsiO(aF>yjDdf8HpqxdajkGoY*D`UBQDL~7&j&6 zfD0t3dKb#?uqr=XqeUMZr@?fZ^D@i(i?`V6qv*F34^!QDxU|#O@0|5<$kL zhxqaal;dxJya_$J?u#>gAFMnfyCjyesnS2kt)SGLke=8D&n7Ed!JOVI@d1dn6ua0c zlfa~k=PCLYzan`=zEvo+fVksf%~#35^`0zK$Lxr8F?p@jDx@UL;gKM^nPTwp^!v!Q zRfW8B`;=c8dD^CLK-n6UhlhPvy!4YA$Ii!P#0ESG!Ykyg93M;X?Wn|0WzFiD_M57W zV91-#k{%!K$2`Rarz~^+?rx?N&Qo)yICI4v7E{)p3PkI?HHK%8tGxE3T87FB2Tk8I zDG&>y#q<+M%vthRr!s;axJ*RA@&{~D7D9M4aOXj`;s*qg-C1C{-W^4;Tj6-@R$_Tv zFx;Xnw#pATR;QtZK

i&5Os8W*b(tk&+j4zl9Z)xc^9I%+cZ$MB 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 Lj@oBc^Jo7DK{hRA literal 0 HcmV?d00001 diff --git a/out/persona-first-qa/forgechat-owner-chat-persona-first-readouts.png b/out/persona-first-qa/forgechat-owner-chat-persona-first-readouts.png new file mode 100644 index 0000000000000000000000000000000000000000..a9e0b332cd59060afe3a3b6f938d89705a52123a GIT binary patch literal 180162 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 z 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%0D1vV`65V2A0|NObXNZ+J}1NAlgGuf@Fvps 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ν$kaEtev^5M<@`zOVcc|tW8#~6ab&A$l?WECFExZhV zpE|8BcDYF#n9L+FiFi=Jq-o$%qu(It$6d5=OeA@0T=hi;^T~?VS0N(V8E0huu$WNN z^lZCjdRZBL_{?nEn+YOo*@YU@MrM}6ydCjeo1C$QB&Q8DlP#$Lnx?n#Z4fe zj`Nx0Ly1)ACc4z0tymq!=kjXx=b$0OEpbk*25FHRi+uK>m{IOr$y6vClct?oF2z;V zC3Y^!>#~`-pMmS&x;sp+P(!D0lsTq8#*np@7)vUFq^eY$k}eljThwTbxwhz~0Xs2f zN@i9ofZW^tHD1|k1&3E!X?o8IX4ZB?2WmNd#xavlYhT6j^~YCs!+h&Mzz9?bvwtK5tU>wSA=y+NJdsy5;@bzGeAgKt26#t z0{|RWOybY&g@mXl%zPJ()68$Ir{R;>Fb}2dia7AWbMcl&@b&*uTar8nk+{i8$QQ7b zp6{J5i^qK{p#wYn&#X&z9-x2REXIAIv!u4N9MSL4WRwYWpI@9DWk>gYElhbm0FEmy z0?#19b6Z0GzH1gGnz7?4$Gyke=RF0BTBEVz&=lU?_msFV<2{WPh6!a^^e3`CM4r(Y zaoS@A?EcCS6tDj4hlS~M28>!S+iPu5RW~eg&G195lig^T{hk!{KZhgblT&8Ox-)A|pX#>o?|{BeAk&QQIy z?OI}*;nF+zRZ)OC&)qxC%p1F0|M?^DwpM|Gpj*>StvQu z_HlMcUhCAUhQknzLY(+Q;f&0L_b(_X7)>3_E*Xt0v2sA7>AZA-l-l`iay}-m#w@q7 zY!Uxu#F*66rT14)(8&SIcCyFHxo3BVn}PE)V#~#$z{CO_)44V)I0S@Q3V3yZ%lT!5d#jI5 zRbIX|B#rAreYe4f4GjydS|-Yc**=m6Ce-yWzijAGG7CEN3fO%=e|yI3#L)T~Y{7R{ z{uQ~t@akcw)zYnD(W^9BclexvNJaLr=PWp5@v z^QuWq=lu((?$Zrr$_-uLIQ3+El&jHQZQvl+Dz>n1p(~#To#)yX zt@P^IxgjwZ&YfQ>UAp*KT~M$7x6>26-_H_z0R4zJ;zcO|DkbJL@f7D4+m+rvU` zo{M_@vbMuKef7_EJ0z3u+&j1M?mb!lzf8cv4E`+zGw+G!3QvCH1g=`*IU$bM;|>LG zed*Ep`{SzdjhVu&o)?YW=L9mbWS!c{`dB_x&VAm;4d>i8vxRlpNu)oiiBj%jh;x?w zwBfU_Fi*X; z>B5|9*LMqSYBT1!)H@v(`qdY*Tk;8W@d5{6+-=z&)7U-jNbP}F-`OvMdmTVSV`(KTW%9r{~Ld9?2?Do`3uF-wE#5^D{z2QfKdW6?}U%(fmkm zPyWtZI-94QNj~J1H=FSy{B?EFfcH%fx2JJ3^BqMobIy@F#$yw NJYD@<);T3K0RSNz!BPMK literal 0 HcmV?d00001 diff --git a/out/persona-first-qa/forgechat-public-owner-chat-aliado-guarded-readout.png b/out/persona-first-qa/forgechat-public-owner-chat-aliado-guarded-readout.png new file mode 100644 index 0000000000000000000000000000000000000000..f3cf31a47780d6e26d24e9263ed5c8c2843536ca GIT binary patch literal 164980 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 UreS+C@y|4xsyZrVN*1C22VY(CMF0Q* literal 0 HcmV?d00001 diff --git a/out/persona-first-qa/forgechat-public-owner-chat-persona-first-readouts.png b/out/persona-first-qa/forgechat-public-owner-chat-persona-first-readouts.png new file mode 100644 index 0000000000000000000000000000000000000000..9d12b0ff8f271c71f78c35a321e999a233cbb998 GIT binary patch literal 144596 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 M07*qoM6N<$g2j9&ivR!s literal 0 HcmV?d00001 diff --git a/out/persona-first-qa/forgechat-public-owner-chat-search-qa-personafirst.png b/out/persona-first-qa/forgechat-public-owner-chat-search-qa-personafirst.png new file mode 100644 index 0000000000000000000000000000000000000000..748760336f00d4d436c93fd7ec280c37e9af5767 GIT binary patch literal 169697 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%0D$x8oFbsYO4W`&N9mZtEd!!o~T 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 N(Nxn_Em5`z`9H}V7>57= literal 0 HcmV?d00001 diff --git a/out/persona-first-qa/forgechat-public-owner-chat-tecnico-guarded-readout.png b/out/persona-first-qa/forgechat-public-owner-chat-tecnico-guarded-readout.png new file mode 100644 index 0000000000000000000000000000000000000000..1f114345ec638a5236653cf66cdeb05f9bf7747b GIT binary patch literal 163153 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%0DNmgF|MH(}%%jA0J41j$GHj;x+ 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 literal 0 HcmV?d00001 diff --git a/out/register-alek-negative-feedback.sql b/out/register-alek-negative-feedback.sql new file mode 100644 index 0000000..69c7d4a --- /dev/null +++ b/out/register-alek-negative-feedback.sql @@ -0,0 +1,16 @@ +BEGIN; +UPDATE coexistence.contacts +SET tags = COALESCE(tags, '[]'::jsonb) || '["feedback-negativo","requiere-alek","pausar-automatizacion"]'::jsonb, + custom_fields = COALESCE(custom_fields, '{}'::jsonb) || jsonb_build_object( + 'ia360_feedback_negativo', 'true', + 'ia360_feedback_negativo_at', '2026-06-02T15:02:02Z', + 'ia360_feedback_negativo_resumen', 'Alek pidió dejar de hacer pruebas sueltas y validar/simular embudos reales.', + 'ia360_stage_guardrail', 'Requiere Alek' + ), + updated_at = now() +WHERE wa_number='5213321594582' AND contact_number='5213322638033'; + +SELECT id||'|'||coalesce(name,'')||'|'||coalesce(tags::text,'[]')||'|'||coalesce(custom_fields->>'ia360_stage_guardrail','') +FROM coexistence.contacts +WHERE wa_number='5213321594582' AND contact_number='5213322638033'; +COMMIT; diff --git a/out/repair-alek-deals.sql b/out/repair-alek-deals.sql new file mode 100644 index 0000000..d7cf0f7 --- /dev/null +++ b/out/repair-alek-deals.sql @@ -0,0 +1,34 @@ +BEGIN; +-- Repair Alek ForgeChat deals after duplicate contact cleanup. +-- Canonical deal id=2, duplicate/fake-WA deal id=3. + +UPDATE coexistence.deals canonical +SET notes = concat_ws(E'\n\n', + nullif(canonical.notes,''), + '[Hermes repair 2026-06-02] Canonical Alek IA360 deal. Stage corrected to Reunión agendada because chat history contains Calendar/Zoom confirmation. Duplicate/fake-WA deal id=3 was closed as lost/internal duplicate.' + ), + stage_id = 13, + status = 'open', + contact_wa_number = '5213321594582', + contact_number = '5213322638033', + contact_name = 'Soy Alek', + updated_at = now() +WHERE canonical.id = 2; + +UPDATE coexistence.deals dup +SET notes = concat_ws(E'\n\n', + nullif(dup.notes,''), + '[Hermes repair 2026-06-02] Closed as internal duplicate/fake-WA residue after Alek contact dedupe. Canonical deal is id=2. Do not use wa_number 5210000000000.' + ), + stage_id = 16, + status = 'lost', + lost_at = coalesce(lost_at, now()), + updated_at = now() +WHERE dup.id = 3 AND dup.contact_wa_number = '5210000000000'; + +SELECT d.id||'|'||coalesce(d.title,'')||'|'||coalesce(d.contact_number,'')||'|'||coalesce(d.contact_wa_number,'')||'|'||coalesce(ps.name,'')||'|'||coalesce(d.status,'') +FROM coexistence.deals d +LEFT JOIN coexistence.pipeline_stages ps ON ps.id=d.stage_id +WHERE d.contact_number='5213322638033' OR d.contact_wa_number IN ('5213321594582','5210000000000') +ORDER BY d.id; +COMMIT; diff --git a/out/repair-backups/alek-deals-before-repair-20260602T155518Z.csv b/out/repair-backups/alek-deals-before-repair-20260602T155518Z.csv new file mode 100644 index 0000000..783a42c --- /dev/null +++ b/out/repair-backups/alek-deals-before-repair-20260602T155518Z.csv @@ -0,0 +1,58 @@ +id,pipeline_id,stage_id,title,value,currency,status,assigned_user_id,contact_wa_number,contact_number,contact_name,expected_close_date,notes,position,won_at,lost_at,created_by,created_at,updated_at +2,2,18,IA360 · Soy Alek AI · Reunión confirmada,0.00,MXN,open,1,5213321594582,5213322638033,Soy Alek AI,,"[2026-06-01T20:53:16.617Z] Input: Diagnóstico; intención inicial detectada +[2026-06-01T20:53:17.423Z] Área de dolor seleccionada: ERP / CRM +[2026-06-01T20:53:17.974Z] Solicitó diagnóstico ligero de 5 preguntas +[2026-06-01T20:53:18.554Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T20:53:19.192Z] Solicitó ejemplo IA360 +[2026-06-01T20:53:19.823Z] Pregunta 1/5: trabajo manual o doble captura en WhatsApp +[2026-06-01T20:53:20.306Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T20:53:20.834Z] Solicitó ejemplo ERP → BI +[2026-06-01T20:53:21.535Z] Solicitó Ver flujo del ejemplo WhatsApp → CRM +[2026-06-01T20:54:48.133Z] Área de dolor seleccionada: Ventas +[2026-06-01T20:55:01.474Z] Solicitó ejemplo IA360 +[2026-06-01T20:55:10.702Z] Solicitó ejemplo WhatsApp → CRM; listo para propuesta/siguiente paso +[2026-06-01T20:55:15.782Z] Solicitó Ver flujo del ejemplo WhatsApp → CRM +[2026-06-01T20:58:09.366Z] Solicitó Ver flujo del ejemplo WhatsApp → CRM +[2026-06-01T20:58:15.912Z] Solicitó detalle: Arquitectura +[2026-06-01T20:58:17.249Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T20:58:26.065Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T20:58:30.901Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T20:58:35.928Z] Solicitó detalle: Costo +[2026-06-01T20:58:40.620Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T20:58:49.287Z] Solicitó detalle: Costo +[2026-06-01T20:58:59.188Z] Solicitó detalle: Alcance +[2026-06-01T21:00:29.112Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T21:01:20.546Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T21:01:21.867Z] Solicitó detalle: Alcance +[2026-06-01T21:01:26.869Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T21:01:28.292Z] Solicitó detalle: Alcance +[2026-06-01T21:03:25.516Z] Solicitó detalle: Alcance +[2026-06-01T21:03:35.058Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T21:05:16.506Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T21:27:10.193Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-01T21:30:18.486Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-01T21:30:24.771Z] 100M flow: Captura manual → Dolor calificado +[2026-06-01T21:30:28.660Z] 100M flow: WhatsApp → CRM → Propuesta / siguiente paso +[2026-06-01T21:30:34.232Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-01T21:30:39.816Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-01T21:30:44.294Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T21:30:48.626Z] Solicitó detalle: Alcance +[2026-06-01T21:30:52.503Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T21:30:59.702Z] Solicitó detalle: Costo +[2026-06-01T21:31:04.317Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T21:31:09.407Z] Solicitó detalle: Alcance +[2026-06-01T21:33:33.368Z] Solicitó detalle: Alcance +[2026-06-01T21:34:18.962Z] Solicitó detalle: Alcance +[2026-06-01T21:34:40.556Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T21:34:46.485Z] Preferencia de agenda seleccionada: Mañana; falta crear evento real +[2026-06-01T21:42:04.310Z] Solicitó detalle: Alcance +[2026-06-01T22:23:07.937Z] Preferencia de agenda seleccionada: Mañana; falta crear evento real +[2026-06-01T22:24:38.188Z] Solicitó detalle: Alcance +[2026-06-01T22:24:43.996Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T22:24:48.366Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-02T00:18:19.045Z] Preferencia de agenda seleccionada: Mañana; falta crear evento real +[2026-06-02T02:00:57.325Z] Reunión confirmada. Calendar event: duk3hiv3tecn3vkco5bb9v5ae0; Zoom meeting: 83902405403; inicio: 2026-06-02T21:00:00.000Z",0,,,1,2026-06-01 20:53:16.663193+00,2026-06-02 02:00:57.345113+00 +3,2,18,IA360 · Soy Alek AI · Agenda Mañana,0.00,MXN,open,1,5210000000000,5213322638033,Soy Alek AI,,"[2026-06-02T01:58:19.620Z] Preferencia de agenda seleccionada: Mañana; falta crear evento real +[2026-06-02T01:59:08.404Z] Preferencia de día seleccionada: Mañana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-02T02:00:40.472Z] Preferencia de día seleccionada: Mañana; se consultó disponibilidad real de Calendar para ofrecer horas libres",1,,,1,2026-06-02 01:58:19.633703+00,2026-06-02 02:00:40.489624+00 +4,2,18,IA360 · IA360 TEST SLOT CLEANUP · Reunión confirmada,0.00,MXN,open,1,5210000000000,521000000001,IA360 TEST SLOT CLEANUP,,[2026-06-02T02:01:36.533Z] Reunión confirmada. Calendar event: cp2ml66l8712mknh8sb22nvhgo; Zoom meeting: 86913742590; inicio: 2026-06-02T16:00:00.000Z,2,,,1,2026-06-02 02:01:36.544226+00,2026-06-02 02:01:36.544226+00 diff --git a/out/update-alek-contact-forgechat.sql b/out/update-alek-contact-forgechat.sql new file mode 100644 index 0000000..00e2ff3 --- /dev/null +++ b/out/update-alek-contact-forgechat.sql @@ -0,0 +1,28 @@ +BEGIN; +UPDATE coexistence.contacts +SET name = 'Soy Alek', + profile_name = COALESCE(NULLIF(profile_name,''), 'Soy Alek'), + custom_fields = COALESCE(custom_fields, '{}'::jsonb) || jsonb_build_object( + 'email', 'Ocompudoc@gmail.com', + 'nombre_canonico', 'Soy Alek', + 'rol', 'Owner GeekStudio / IA360', + 'tipo_contacto', 'internal_test_owner', + 'identificado_por_hermes', '2026-06-02' + ), + updated_at = now() +WHERE wa_number='5213321594582' AND contact_number='5213322638033'; + +UPDATE coexistence.contacts +SET custom_fields = COALESCE(custom_fields, '{}'::jsonb) || jsonb_build_object( + 'email', 'Ocompudoc@gmail.com', + 'nombre_canonico', 'Soy Alek', + 'tipo_contacto', 'internal_test_owner' + ), + updated_at = now() +WHERE contact_number='5213322638033'; + +SELECT id || '|' || wa_number || '|' || contact_number || '|' || coalesce(name,'') || '|' || coalesce(profile_name,'') || '|' || coalesce(custom_fields::text,'{}') +FROM coexistence.contacts +WHERE contact_number='5213322638033' +ORDER BY id; +COMMIT; diff --git a/out/update-ia360-handoff-history.sql b/out/update-ia360-handoff-history.sql new file mode 100644 index 0000000..b2127f4 --- /dev/null +++ b/out/update-ia360-handoff-history.sql @@ -0,0 +1,2 @@ +UPDATE workflow_history SET nodes='[{"parameters": {"httpMethod": "POST", "path": "ia360-whatsapp-handoff", "responseMode": "responseNode", "options": {}}, "id": "Webhook_IA360_Handoff", "name": "Webhook IA360 Handoff", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [-620, 0]}, {"parameters": {"mode": "runOnceForAllItems", "jsCode": "\nconst payload = $input.first().json.body || $input.first().json;\nfunction cleanDigits(v) { return String(v || '''').replace(/\\D/g, ''''); }\nfunction required(v, name) { if (!v) throw new Error(`Missing ${name}`); return v; }\nfunction mapEspoStage(eventType, targetStage) {\n if (eventType === ''proposal_requested'' || targetStage === ''Propuesta / siguiente paso'') return ''Proposal'';\n if (eventType === ''opt_out'') return ''Closed Lost'';\n // Current EspoCRM install only exposes Prospecting, Qualification, Proposal,\n // Negotiation, Closed Won, Closed Lost. Until custom stages exist, confirmed\n // meetings stay Qualification, while ForgeChat carries the precise\n // Reunión agendada conversational state.\n if (eventType === ''meeting_confirmed_calendar_zoom'') return ''Qualification'';\n if (eventType === ''agenda_preference_selected'' || eventType === ''call_requested'') return ''Qualification'';\n if (eventType === ''map_requested'' || eventType === ''flow_map_requested'' || eventType === ''example_requested'' || eventType === ''mechanism_selected'' || eventType === ''pain_segmented'') return ''Qualification'';\n if (eventType === ''nurture_selected'') return null;\n return targetStage ? ''Qualification'' : ''Prospecting'';\n}\nfunction shouldCreateTask(eventType, priority) {\n return priority === ''high'' || [\n ''apply_requested'', ''scope_requested'', ''proposal_requested'', ''call_requested'',\n ''agenda_preference_selected'', ''meeting_confirmed_calendar_zoom'', ''diagnostic_answered''\n ].includes(eventType);\n}\nfunction recommendedOffer(eventType, targetStage, summary) {\n const text = `${eventType || ''''} ${targetStage || ''''} ${summary || ''''}`.toLowerCase();\n if (text.includes(''erp'') || text.includes(''bi'') || text.includes(''datapower'') || text.includes(''reporte'')) return ''DataPower BI / ERP analytics'';\n if (text.includes(''whatsapp'') || text.includes(''crm'')) return ''WhatsApp Revenue OS / CRM conversacional'';\n if (text.includes(''agent'') || text.includes(''follow'')) return ''Agentic Follow-up'';\n if (text.includes(''gobierno'')) return ''Gobierno IA'';\n if (eventType === ''meeting_confirmed_calendar_zoom'' || eventType === ''call_requested'') return ''Discovery IA360'';\n return ''Diagnóstico IA360 / Mapa 30-60-90'';\n}\nconst espoBase = required($env.ESPOCRM_URL, ''ESPOCRM_URL'').replace(/\\/$/, '''') + ''/api/v1'';\nconst espoUser = required($env.ESPOCRM_ADMIN_USERNAME, ''ESPOCRM_ADMIN_USERNAME'');\nconst espoPass = required($env.ESPOCRM_ADMIN_PASSWORD, ''ESPOCRM_ADMIN_PASSWORD'');\nconst auth = ''Basic '' + Buffer.from(`${espoUser}:${espoPass}`).toString(''base64'');\nasync function espo(method, path, body) {\n const opts = { method, url: espoBase + path, headers: { Authorization: auth, ''Content-Type'': ''application/json'' }, json: true };\n if (body) opts.body = body;\n try {\n return await this.helpers.httpRequest(opts);\n } catch (e) {\n throw new Error(`Espo ${method} ${path} failed: ${e.message}`);\n }\n}\nasync function findByName(entity, name) {\n const qs = ''where%5B0%5D%5Btype%5D=equals'' + ''&where%5B0%5D%5Battribute%5D=name'' + ''&where%5B0%5D%5Bvalue%5D='' + encodeURIComponent(name) + ''&maxSize=1'';\n const data = await espo.call(this, ''GET'', `/${entity}?${qs}`);\n return (data.list || [])[0] || null;\n}\nasync function upsertByName(entity, name, body) {\n const existing = await findByName.call(this, entity, name);\n if (existing && existing.id) {\n const updated = await espo.call(this, ''PUT'', `/${entity}/${existing.id}`, body);\n return { action: ''updated'', record: updated };\n }\n const created = await espo.call(this, ''POST'', `/${entity}`, body);\n return { action: ''created'', record: created };\n}\nif (payload.source !== ''forgechat-ia360-webhook'') throw new Error(''Invalid source'');\nconst contact = payload.contact || {};\nconst trigger = payload.trigger || {};\nconst wa = cleanDigits(contact.contactNumber);\nif (!wa) throw new Error(''Missing contact.contactNumber'');\nconst displayName = contact.contactName || `WhatsApp ${wa}`;\nconst eventType = payload.eventType || ''ia360_handoff'';\nconst targetStage = payload.targetStage || ''Agenda en proceso'';\nconst priorityRaw = payload.priority || ''normal'';\nconst priority = priorityRaw === ''high'' ? ''High'' : ''Normal'';\nconst summary = payload.summary || '''';\nconst espoStage = mapEspoStage(eventType, targetStage);\nconst createTask = shouldCreateTask(eventType, priorityRaw);\nconst offer = recommendedOffer(eventType, targetStage, summary);\nconst marker = ''[ForgeChat IA360 n8n Sync]'';\nconst accountName = ''ForgeChat IA360 WhatsApp'';\nconst contactName = `${displayName} WhatsApp IA360`;\nconst opportunityName = `IA360 WhatsApp - ${displayName}`;\nconst taskName = `IA360 WhatsApp handoff - ${displayName}`;\nconst descriptionBase = `${marker}\\nEvento: ${eventType}\\nPrioridad: ${priorityRaw}\\nStage ForgeChat: ${targetStage}\\nStage Espo recomendado: ${espoStage || ''N/A''}\\nOferta sugerida: ${offer}\\nContacto WA: +${wa}\\nNombre: ${displayName}\\nÚltima respuesta: ${trigger.messageBody || ''''}\\nResumen: ${summary}\\nOcurrió: ${payload.occurredAt || new Date().toISOString()}\\nAcciones recomendadas: ${(payload.recommendedActions || []).join('', '')}`;\nconst appUser = await espo.call(this, ''GET'', ''/App/user'');\nconst currentUser = appUser.user || appUser;\nconst assignedUserId = currentUser.id;\nconst assignedUserName = currentUser.name || currentUser.userName || ''Admin'';\nconst accountRes = await upsertByName.call(this, ''Account'', accountName, { name: accountName, type: ''Customer'', assignedUserId, assignedUserName, description: `${marker}\\nCuenta puente para handoffs IA360 generados desde ForgeChat WhatsApp. Último WA: +${wa}.` });\nconst parts = displayName.split(/\\s+/);\nconst firstName = parts.slice(0, -1).join('' '') || displayName;\nconst lastName = parts.length > 1 ? `${parts[parts.length - 1]} WhatsApp IA360` : ''WhatsApp IA360'';\nconst contactRes = await upsertByName.call(this, ''Contact'', contactName, { firstName, lastName, assignedUserId, assignedUserName, description: descriptionBase + ''\\n\\nNota: WA se guarda en descripción para evitar rechazo por validación estricta de phoneNumber en EspoCRM.'' });\nlet opportunityRes = null;\nif (espoStage) {\n const closeDate = new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10);\n opportunityRes = await upsertByName.call(this, ''Opportunity'', opportunityName, { name: opportunityName, stage: espoStage, amount: 0, closeDate, assignedUserId, assignedUserName, description: descriptionBase });\n}\nlet taskRes = null;\nif (createTask) {\n const due = new Date(Date.now() + (priority === ''High'' ? 2 : 24) * 3600000).toISOString().slice(0, 19).replace(''T'', '' '');\n const nextAction = eventType === ''meeting_confirmed_calendar_zoom''\n ? ''Preparar briefing humano para reunión confirmada; Calendar/Zoom ya existen.''\n : eventType === ''call_requested'' || eventType === ''agenda_preference_selected''\n ? ''Revisar contexto en ForgeChat y confirmar horario real; no contar como reunión confirmada hasta crear evento.''\n : eventType === ''proposal_requested''\n ? ''Preparar alcance/costo con base en contexto y oferta sugerida.''\n : ''Revisar contexto y definir siguiente acción humana.'';\n taskRes = await upsertByName.call(this, ''Task'', taskName, { name: taskName, status: ''Not Started'', priority, dateEnd: due, assignedUserId, assignedUserName, description: descriptionBase + `\\n\\nSiguiente acción humana: ${nextAction}` });\n}\nreturn [{ json: { ok: true, source: ''n8n-ia360-handoff-code-httpRequest'', eventType, wa, mapping: { targetStage, espoStage, createTask, offer }, espocrm: { account: { action: accountRes.action, id: accountRes.record.id, name: accountRes.record.name }, contact: { action: contactRes.action, id: contactRes.record.id, name: contactRes.record.name }, opportunity: opportunityRes ? { action: opportunityRes.action, id: opportunityRes.record.id, name: opportunityRes.record.name, stage: opportunityRes.record.stage } : null, task: taskRes ? { action: taskRes.action, id: taskRes.record.id, name: taskRes.record.name, priority: taskRes.record.priority, status: taskRes.record.status } : null } } }];\n"}, "id": "Code_EspoCRM_Sync", "name": "Code EspoCRM Sync", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [-300, 0]}, {"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}, "id": "Respond_OK", "name": "Respond OK", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [20, 0]}]'::json, connections='{"Webhook IA360 Handoff": {"main": [[{"node": "Code EspoCRM Sync", "type": "main", "index": 0}]]}, "Code EspoCRM Sync": {"main": [[{"node": "Respond OK", "type": "main", "index": 0}]]}}'::json, "updatedAt"=now(), "nodeGroups"='[]'::json WHERE "workflowId"='IA360WhatsAppHandoffDraft20260601' AND "versionId"='360c0bc2-0aac-49dd-9fd7-9196f48b6db5'; +SELECT "versionId", substring(nodes::text from 'shouldCreateTask') is not null FROM workflow_history WHERE "workflowId"='IA360WhatsAppHandoffDraft20260601' AND "versionId"='360c0bc2-0aac-49dd-9fd7-9196f48b6db5'; diff --git a/out/update-ia360-handoff-workflow.sql b/out/update-ia360-handoff-workflow.sql new file mode 100644 index 0000000..ad840db --- /dev/null +++ b/out/update-ia360-handoff-workflow.sql @@ -0,0 +1,2 @@ +UPDATE workflow_entity SET "nodes"='[{"parameters": {"httpMethod": "POST", "path": "ia360-whatsapp-handoff", "responseMode": "responseNode", "options": {}}, "id": "Webhook_IA360_Handoff", "name": "Webhook IA360 Handoff", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [-620, 0]}, {"parameters": {"mode": "runOnceForAllItems", "jsCode": "\nconst payload = $input.first().json.body || $input.first().json;\nfunction cleanDigits(v) { return String(v || '''').replace(/\\D/g, ''''); }\nfunction required(v, name) { if (!v) throw new Error(`Missing ${name}`); return v; }\nfunction mapEspoStage(eventType, targetStage) {\n if (eventType === ''proposal_requested'' || targetStage === ''Propuesta / siguiente paso'') return ''Proposal'';\n if (eventType === ''opt_out'') return ''Closed Lost'';\n // Current EspoCRM install only exposes Prospecting, Qualification, Proposal,\n // Negotiation, Closed Won, Closed Lost. Until custom stages exist, confirmed\n // meetings stay Qualification, while ForgeChat carries the precise\n // Reunión agendada conversational state.\n if (eventType === ''meeting_confirmed_calendar_zoom'') return ''Qualification'';\n if (eventType === ''agenda_preference_selected'' || eventType === ''call_requested'') return ''Qualification'';\n if (eventType === ''map_requested'' || eventType === ''flow_map_requested'' || eventType === ''example_requested'' || eventType === ''mechanism_selected'' || eventType === ''pain_segmented'') return ''Qualification'';\n if (eventType === ''nurture_selected'') return null;\n return targetStage ? ''Qualification'' : ''Prospecting'';\n}\nfunction shouldCreateTask(eventType, priority) {\n return priority === ''high'' || [\n ''apply_requested'', ''scope_requested'', ''proposal_requested'', ''call_requested'',\n ''agenda_preference_selected'', ''meeting_confirmed_calendar_zoom'', ''diagnostic_answered''\n ].includes(eventType);\n}\nfunction recommendedOffer(eventType, targetStage, summary) {\n const text = `${eventType || ''''} ${targetStage || ''''} ${summary || ''''}`.toLowerCase();\n if (text.includes(''erp'') || text.includes(''bi'') || text.includes(''datapower'') || text.includes(''reporte'')) return ''DataPower BI / ERP analytics'';\n if (text.includes(''whatsapp'') || text.includes(''crm'')) return ''WhatsApp Revenue OS / CRM conversacional'';\n if (text.includes(''agent'') || text.includes(''follow'')) return ''Agentic Follow-up'';\n if (text.includes(''gobierno'')) return ''Gobierno IA'';\n if (eventType === ''meeting_confirmed_calendar_zoom'' || eventType === ''call_requested'') return ''Discovery IA360'';\n return ''Diagnóstico IA360 / Mapa 30-60-90'';\n}\nconst espoBase = required($env.ESPOCRM_URL, ''ESPOCRM_URL'').replace(/\\/$/, '''') + ''/api/v1'';\nconst espoUser = required($env.ESPOCRM_ADMIN_USERNAME, ''ESPOCRM_ADMIN_USERNAME'');\nconst espoPass = required($env.ESPOCRM_ADMIN_PASSWORD, ''ESPOCRM_ADMIN_PASSWORD'');\nconst auth = ''Basic '' + Buffer.from(`${espoUser}:${espoPass}`).toString(''base64'');\nasync function espo(method, path, body) {\n const opts = { method, url: espoBase + path, headers: { Authorization: auth, ''Content-Type'': ''application/json'' }, json: true };\n if (body) opts.body = body;\n try {\n return await this.helpers.httpRequest(opts);\n } catch (e) {\n throw new Error(`Espo ${method} ${path} failed: ${e.message}`);\n }\n}\nasync function findByName(entity, name) {\n const qs = ''where%5B0%5D%5Btype%5D=equals'' + ''&where%5B0%5D%5Battribute%5D=name'' + ''&where%5B0%5D%5Bvalue%5D='' + encodeURIComponent(name) + ''&maxSize=1'';\n const data = await espo.call(this, ''GET'', `/${entity}?${qs}`);\n return (data.list || [])[0] || null;\n}\nasync function upsertByName(entity, name, body) {\n const existing = await findByName.call(this, entity, name);\n if (existing && existing.id) {\n const updated = await espo.call(this, ''PUT'', `/${entity}/${existing.id}`, body);\n return { action: ''updated'', record: updated };\n }\n const created = await espo.call(this, ''POST'', `/${entity}`, body);\n return { action: ''created'', record: created };\n}\nif (payload.source !== ''forgechat-ia360-webhook'') throw new Error(''Invalid source'');\nconst contact = payload.contact || {};\nconst trigger = payload.trigger || {};\nconst wa = cleanDigits(contact.contactNumber);\nif (!wa) throw new Error(''Missing contact.contactNumber'');\nconst displayName = contact.contactName || `WhatsApp ${wa}`;\nconst eventType = payload.eventType || ''ia360_handoff'';\nconst targetStage = payload.targetStage || ''Agenda en proceso'';\nconst priorityRaw = payload.priority || ''normal'';\nconst priority = priorityRaw === ''high'' ? ''High'' : ''Normal'';\nconst summary = payload.summary || '''';\nconst espoStage = mapEspoStage(eventType, targetStage);\nconst createTask = shouldCreateTask(eventType, priorityRaw);\nconst offer = recommendedOffer(eventType, targetStage, summary);\nconst marker = ''[ForgeChat IA360 n8n Sync]'';\nconst accountName = ''ForgeChat IA360 WhatsApp'';\nconst contactName = `${displayName} WhatsApp IA360`;\nconst opportunityName = `IA360 WhatsApp - ${displayName}`;\nconst taskName = `IA360 WhatsApp handoff - ${displayName}`;\nconst descriptionBase = `${marker}\\nEvento: ${eventType}\\nPrioridad: ${priorityRaw}\\nStage ForgeChat: ${targetStage}\\nStage Espo recomendado: ${espoStage || ''N/A''}\\nOferta sugerida: ${offer}\\nContacto WA: +${wa}\\nNombre: ${displayName}\\nÚltima respuesta: ${trigger.messageBody || ''''}\\nResumen: ${summary}\\nOcurrió: ${payload.occurredAt || new Date().toISOString()}\\nAcciones recomendadas: ${(payload.recommendedActions || []).join('', '')}`;\nconst appUser = await espo.call(this, ''GET'', ''/App/user'');\nconst currentUser = appUser.user || appUser;\nconst assignedUserId = currentUser.id;\nconst assignedUserName = currentUser.name || currentUser.userName || ''Admin'';\nconst accountRes = await upsertByName.call(this, ''Account'', accountName, { name: accountName, type: ''Customer'', assignedUserId, assignedUserName, description: `${marker}\\nCuenta puente para handoffs IA360 generados desde ForgeChat WhatsApp. Último WA: +${wa}.` });\nconst parts = displayName.split(/\\s+/);\nconst firstName = parts.slice(0, -1).join('' '') || displayName;\nconst lastName = parts.length > 1 ? `${parts[parts.length - 1]} WhatsApp IA360` : ''WhatsApp IA360'';\nconst contactRes = await upsertByName.call(this, ''Contact'', contactName, { firstName, lastName, assignedUserId, assignedUserName, description: descriptionBase + ''\\n\\nNota: WA se guarda en descripción para evitar rechazo por validación estricta de phoneNumber en EspoCRM.'' });\nlet opportunityRes = null;\nif (espoStage) {\n const closeDate = new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10);\n opportunityRes = await upsertByName.call(this, ''Opportunity'', opportunityName, { name: opportunityName, stage: espoStage, amount: 0, closeDate, assignedUserId, assignedUserName, description: descriptionBase });\n}\nlet taskRes = null;\nif (createTask) {\n const due = new Date(Date.now() + (priority === ''High'' ? 2 : 24) * 3600000).toISOString().slice(0, 19).replace(''T'', '' '');\n const nextAction = eventType === ''meeting_confirmed_calendar_zoom''\n ? ''Preparar briefing humano para reunión confirmada; Calendar/Zoom ya existen.''\n : eventType === ''call_requested'' || eventType === ''agenda_preference_selected''\n ? ''Revisar contexto en ForgeChat y confirmar horario real; no contar como reunión confirmada hasta crear evento.''\n : eventType === ''proposal_requested''\n ? ''Preparar alcance/costo con base en contexto y oferta sugerida.''\n : ''Revisar contexto y definir siguiente acción humana.'';\n taskRes = await upsertByName.call(this, ''Task'', taskName, { name: taskName, status: ''Not Started'', priority, dateEnd: due, assignedUserId, assignedUserName, description: descriptionBase + `\\n\\nSiguiente acción humana: ${nextAction}` });\n}\nreturn [{ json: { ok: true, source: ''n8n-ia360-handoff-code-httpRequest'', eventType, wa, mapping: { targetStage, espoStage, createTask, offer }, espocrm: { account: { action: accountRes.action, id: accountRes.record.id, name: accountRes.record.name }, contact: { action: contactRes.action, id: contactRes.record.id, name: contactRes.record.name }, opportunity: opportunityRes ? { action: opportunityRes.action, id: opportunityRes.record.id, name: opportunityRes.record.name, stage: opportunityRes.record.stage } : null, task: taskRes ? { action: taskRes.action, id: taskRes.record.id, name: taskRes.record.name, priority: taskRes.record.priority, status: taskRes.record.status } : null } } }];\n"}, "id": "Code_EspoCRM_Sync", "name": "Code EspoCRM Sync", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [-300, 0]}, {"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}, "id": "Respond_OK", "name": "Respond OK", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [20, 0]}]'::json, "connections"='{"Webhook IA360 Handoff": {"main": [[{"node": "Code EspoCRM Sync", "type": "main", "index": 0}]]}, "Code EspoCRM Sync": {"main": [[{"node": "Respond OK", "type": "main", "index": 0}]]}}'::json, "settings"='{"executionOrder": "v1"}'::json, "staticData"=NULL, "pinData"='{}'::json, "meta"='{"templateCredsSetupCompleted": true}'::json, "nodeGroups"='[]'::json, "updatedAt"=now(), "versionCounter"="versionCounter"+1 WHERE id='IA360WhatsAppHandoffDraft20260601'; +SELECT id,name,active,"versionCounter" FROM workflow_entity WHERE id='IA360WhatsAppHandoffDraft20260601'; diff --git a/out/upsert-ia360-whatsapp-templates.sql b/out/upsert-ia360-whatsapp-templates.sql new file mode 100644 index 0000000..7de1619 --- /dev/null +++ b/out/upsert-ia360-whatsapp-templates.sql @@ -0,0 +1,76 @@ +BEGIN; +DROP TABLE IF EXISTS ia360_template_upsert; +CREATE TEMP TABLE ia360_template_upsert( + name text, + category text, + language text, + header_text text, + body text, + footer text, + buttons jsonb +); + +INSERT INTO ia360_template_upsert VALUES +('ia360_os_01_apertura_dolor','MARKETING','es_MX','IA360: apertura dolor',$$Hola {{1}}, una pregunta directa: ¿dónde crees que tu empresa pierde más dinero hoy: trabajo manual, datos tarde o seguimiento inconsistente? + +La idea no es venderte más software. Es detectar el cuello que sí movería la aguja en 30 días.$$,'IA360 · diagnóstico ligero','[{"type":"QUICK_REPLY","text":"Diagnóstico rápido"},{"type":"QUICK_REPLY","text":"Ver mapa 30-60-90"},{"type":"QUICK_REPLY","text":"No ahora"}]'::jsonb), +('ia360_os_02_segmenta_dolor','MARKETING','es_MX','IA360: segmentar dolor',$$Perfecto. Para priorizar bien, ¿qué síntoma pesa más hoy?$$,'Una respuesta basta','[{"type":"QUICK_REPLY","text":"Captura manual"},{"type":"QUICK_REPLY","text":"Reportes tarde"},{"type":"QUICK_REPLY","text":"Seguimiento ventas"}]'::jsonb), +('ia360_os_03_mecanismo','MARKETING','es_MX','IA360: mecanismo',$$El mecanismo no es poner ChatGPT. Es montar una capa IA360 sobre tu operación: WhatsApp, CRM, ERP, BI y agentes con control humano. + +¿Qué ejemplo quieres ver primero?$$,'Humano en control','[{"type":"QUICK_REPLY","text":"WhatsApp → CRM"},{"type":"QUICK_REPLY","text":"ERP → BI"},{"type":"QUICK_REPLY","text":"Agente follow-up"}]'::jsonb), +('ia360_os_04_mapa_30_60_90','MARKETING','es_MX','IA360: mapa 30-60-90',$$Si tu caso tiene sentido, el siguiente paso es un mapa 30-60-90: + +• quick wins +• integraciones necesarias +• primer agente o tablero +• riesgos y gobierno +• siguiente acción comercial + +¿Quieres aterrizarlo?$$,'Mapa accionable','[{"type":"QUICK_REPLY","text":"Quiero mapa"},{"type":"QUICK_REPLY","text":"Ver ejemplo"},{"type":"QUICK_REPLY","text":"Agendar"}]'::jsonb), +('ia360_os_05_fit_prioridad','MARKETING','es_MX','IA360: fit prioridad',$$Para no saturarte: ¿qué tan prioritario es resolver esto?$$,'Priorización','[{"type":"QUICK_REPLY","text":"Sí, urgente"},{"type":"QUICK_REPLY","text":"Estoy explorando"},{"type":"QUICK_REPLY","text":"No prioritario"}]'::jsonb), +('ia360_os_06_reactivacion','MARKETING','es_MX','IA360: reactivación',$$Te dejo un criterio práctico: si una tarea se repite, depende de WhatsApp/Excel o retrasa decisiones, probablemente hay una oportunidad IA360. + +Cuando quieras, lo convertimos en un mapa concreto.$$,'Reactivación suave','[{"type":"QUICK_REPLY","text":"Aplicarlo"},{"type":"QUICK_REPLY","text":"Más adelante"},{"type":"QUICK_REPLY","text":"Baja"}]'::jsonb), +('ia360_os_call_requested','UTILITY','es_MX','IA360: llamada solicitada',$$Quedó marcada tu solicitud de llamada con Alek. Para prepararla bien, necesitamos ubicar objetivo, sistema actual e integración principal. + +¿Quieres elegir horario o mandar contexto primero?$$,'Preparación de llamada','[{"type":"QUICK_REPLY","text":"Elegir horario"},{"type":"QUICK_REPLY","text":"Enviar contexto"},{"type":"QUICK_REPLY","text":"Más tarde"}]'::jsonb), +('ia360_os_meeting_confirmed','UTILITY','es_MX','IA360: reunión confirmada',$$Listo, reunión confirmada con Alek. + +Hora: {{1}} +Zoom: {{2}} + +También quedó en Calendar.$$,'Reunión IA360','[{"type":"QUICK_REPLY","text":"Enviar contexto"},{"type":"QUICK_REPLY","text":"Reagendar"},{"type":"QUICK_REPLY","text":"Cancelar"}]'::jsonb), +('ia360_os_meeting_reminder','UTILITY','es_MX','IA360: recordatorio reunión',$$Recordatorio: tienes reunión con Alek hoy a las {{1}}. + +Objetivo: revisar {{2}} y definir siguiente paso IA360.$$,'Recordatorio','[{"type":"QUICK_REPLY","text":"Confirmo"},{"type":"QUICK_REPLY","text":"Reagendar"},{"type":"QUICK_REPLY","text":"Enviar contexto"}]'::jsonb), +('ia360_os_post_meeting_next','UTILITY','es_MX','IA360: post reunión',$$Gracias por la llamada. Con lo revisado, el siguiente paso es aterrizar {{1}}. + +¿Quieres que Alek prepare alcance, costo o mapa 30-60-90?$$,'Siguiente paso','[{"type":"QUICK_REPLY","text":"Alcance"},{"type":"QUICK_REPLY","text":"Costo"},{"type":"QUICK_REPLY","text":"Mapa 30-60-90"}]'::jsonb); + +UPDATE coexistence.message_templates mt +SET category = u.category, + language = u.language, + header_type = 'TEXT', + header_text = u.header_text, + body = u.body, + footer = u.footer, + buttons = u.buttons, + status = 'DRAFT', + allow_category_change = true, + updated_at = now() +FROM ia360_template_upsert u +WHERE mt.name = u.name; + +INSERT INTO coexistence.message_templates + (name, category, language, header_type, header_text, body, footer, buttons, status, allow_category_change, created_at, updated_at) +SELECT u.name, u.category, u.language, 'TEXT', u.header_text, u.body, u.footer, u.buttons, 'DRAFT', true, now(), now() +FROM ia360_template_upsert u +WHERE NOT EXISTS ( + SELECT 1 FROM coexistence.message_templates mt WHERE mt.name = u.name +); + +SELECT name || '|' || status || '|' || category +FROM coexistence.message_templates +WHERE name LIKE 'ia360_os_%' +ORDER BY name; +COMMIT; diff --git a/revenue-os-e2e.sh b/revenue-os-e2e.sh new file mode 100644 index 0000000..5063175 --- /dev/null +++ b/revenue-os-e2e.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# ============================================================================ +# E2E — Pipeline 5 "WhatsApp Revenue OS" (flujo de apertura, 3 pasos) +# Corre EN EL VPS, contra el owner 5213322638033 (staged). Egress real al owner. +# Requiere: backend ya desplegado con el código Revenue OS. +# ssh alek@cloud-alek-01.tail0c281d.ts.net 'bash -s' < revenue-os-e2e.sh +# o copiarlo al VPS y ejecutarlo allí. +# ============================================================================ +set -uo pipefail + +WA="5213321594582" # wa_number cuenta IA360 (display_phone_number) +OWNER="5213322638033" # contacto owner (Alek), staged +PID="123456789" # phone_number_id placeholder (resolveAccount usa wa_number) +DB="forgecrm-db" +BE="forgecrm-backend" +ENVF="/home/alek/stack/forgechat-poc/backend/.env" + +APP_SECRET="$(grep -E '^META_APP_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +DIR_SECRET="$(grep -E '^IA360_DIRECTIVE_SECRET=' "$ENVF" | head -1 | cut -d= -f2-)" +# Puerto real donde escucha el backend DENTRO del contenedor (robusto: lee el env del contenedor). +PORT="$(docker exec "$BE" printenv PORT 2>/dev/null)"; PORT="${PORT:-3001}" +BASE="http://localhost:${PORT}/api" + +PASS=0; FAIL=0 +ok(){ echo " [PASS] $1"; PASS=$((PASS+1)); } +bad(){ echo " [FAIL] $1 (esperado='$2' obtuvo='$3')"; FAIL=$((FAIL+1)); } +chk(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$3" "$2"; fi; } +chk_has(){ if echo "$2" | grep -qF "$3"; then ok "$1"; else bad "$1" "contiene:$3" "$2"; fi; } + +psql_q(){ docker exec "$DB" psql -U postgres -d postgres -tAc "$1"; } + +# Cliente HTTP dentro del contenedor con node (fetch). El body va por STDIN para no +# pelear con el quoting de acentos/comillas. Imprime el status HTTP. +NODE_POST='let d="";process.stdin.on("data",c=>d+=c).on("end",async()=>{try{const h={"content-type":"application/json"};if(process.env.SIG)h["x-hub-signature-256"]=process.env.SIG;if(process.env.SECRET)h["X-IA360-Directive-Secret"]=process.env.SECRET;const r=await fetch(process.env.URL,{method:"POST",headers:h,body:d});const t=await r.text();process.stdout.write(String(r.status)+(process.env.SHOWBODY?(" "+t):""));}catch(e){process.stdout.write("ERR "+e.message);}});' + +# Firma HMAC (host) + POST del payload Meta al webhook (vía node en el contenedor). +post_webhook(){ + local body="$1" + local sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)" + printf '%s' "$body" | docker exec -i -e SIG="$sig" -e URL="$BASE/webhook/whatsapp" "$BE" node -e "$NODE_POST" +} + +ts(){ date +%s; } +wamid(){ echo "wamid.e2e.revenueos.$1.$(ts).$RANDOM"; } + +latest_out_label(){ # $1=label -> message_body de la última saliente con ese label + psql_q "SELECT message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'label'='$1' ORDER BY id DESC LIMIT 1" +} +out_count_handler(){ # $1=handler_for -> # de salientes con ese ia360_handler_for + psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'ia360_handler_for'='$1'" +} +state(){ psql_q "SELECT custom_fields->>'ia360_revenue_state' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$OWNER'"; } +p5_stage(){ psql_q "SELECT s.name FROM coexistence.deals d JOIN coexistence.pipeline_stages s ON s.id=d.stage_id JOIN coexistence.pipelines p ON p.id=d.pipeline_id WHERE p.name='WhatsApp Revenue OS' AND d.contact_number='$OWNER' ORDER BY d.updated_at DESC NULLS LAST, d.id DESC LIMIT 1"; } +has_tag(){ psql_q "SELECT EXISTS(SELECT 1 FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$OWNER' AND tags ? '$1')"; } + +echo "=== SECRETS check ===" +[ -n "$APP_SECRET" ] && ok "META_APP_SECRET presente" || echo " [WARN] META_APP_SECRET vacío (webhook sin firma → revisar verifyMetaSignature)" +[ -n "$DIR_SECRET" ] && ok "IA360_DIRECTIVE_SECRET presente" || bad "IA360_DIRECTIVE_SECRET" "no-vacío" "vacío" + +echo "=== STEP 0 — limpieza estado P5 del owner (NO toca P2 ni bookings) ===" +psql_q "DELETE FROM coexistence.deals WHERE contact_number='$OWNER' AND pipeline_id=(SELECT id FROM coexistence.pipelines WHERE name='WhatsApp Revenue OS')" >/dev/null +psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields - 'ia360_revenue_state' - 'ia360_revenue_dolor' - 'ia360_revenue_canal' - 'ia360_revenue_volumen' - 'ia360_revenue_calificacion_raw' - 'ia360_revenue_started_at', tags = (SELECT COALESCE(jsonb_agg(DISTINCT v),'[]'::jsonb) FROM jsonb_array_elements_text(tags) v WHERE v NOT IN ('nutricion-suave','revenue-os-interesado','revenue-os-calificado','revenue-os-diseno-propuesto','revenue-os-handoff-agenda','pipeline:revenue-os')) WHERE wa_number='$WA' AND contact_number='$OWNER'" >/dev/null +ok "estado P5 limpio" + +echo "=== PASO 1 — apertura (template ia360_os_revenue_apertura) ===" +OPENER_RES="$(printf '%s' "{\"contact_number\":\"$OWNER\",\"name\":\"Alejandro Orozco Flores\"}" | docker exec -i -e SECRET="$DIR_SECRET" -e URL="$BASE/internal/ia360-revenue/opener" -e SHOWBODY=1 "$BE" node -e "$NODE_POST")" +echo " opener resp: $OPENER_RES" +sleep 6 +chk "PASO1 estado=apertura_sent" "$(state)" "apertura_sent" +chk "PASO1 deal P5 en 'Leads desorganizados'" "$(p5_stage)" "Leads desorganizados" +APERTURA_ROW="$(psql_q "SELECT status||' | '||message_body FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'template_name'='ia360_os_revenue_apertura' ORDER BY id DESC LIMIT 1")" +echo " apertura chat_history: $APERTURA_ROW" +chk_has "PASO1 copy de apertura presente" "$APERTURA_ROW" "soy la IA de Alek" +APERTURA_SENT="$(psql_q "SELECT status FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'template_name'='ia360_os_revenue_apertura' ORDER BY id DESC LIMIT 1")" +chk "PASO1 status=sent (render real, no status=delivered)" "$APERTURA_SENT" "sent" +# Eyeball del render de {{1}}=nombre: imprimir componentes enviados si messageSender los persiste. +echo " apertura render check (nombre): $(psql_q "SELECT CASE WHEN message_body LIKE '%{{1}}%' THEN 'BODY-CRUDO(render en components Meta)' ELSE 'BODY-RENDERIZADO' END FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'template_name'='ia360_os_revenue_apertura' ORDER BY id DESC LIMIT 1")" + +echo "=== PASO 1->2 — tap 'Sí, cuéntame' (button de template) ===" +W1="$(wamid si)" +BODY1="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"WABA\",\"changes\":[{\"field\":\"messages\",\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID\"},\"contacts\":[{\"wa_id\":\"$OWNER\",\"profile\":{\"name\":\"Alejandro Orozco Flores\"}}],\"messages\":[{\"from\":\"$OWNER\",\"id\":\"$W1\",\"timestamp\":\"$(ts)\",\"type\":\"button\",\"button\":{\"text\":\"Sí, cuéntame\",\"payload\":\"Sí, cuéntame\"}}]}}]}]}" +echo " http=$(post_webhook "$BODY1")" +sleep 5 +chk "PASO2 estado=calificacion" "$(state)" "calificacion" +PASO2_BODY="$(latest_out_label ia360_os_revenue_paso2)" +echo " paso2 saliente: $PASO2_BODY" +chk_has "PASO2 pregunta de calificación enviada" "$PASO2_BODY" "cómo le siguen el rastro" + +echo "=== PASO 2->3 — texto libre de calificación ===" +W2="$(wamid texto)" +QTXT="Hoy se confian a la memoria y un Excel, se nos pierden como 20 leads al mes" +BODY2="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"WABA\",\"changes\":[{\"field\":\"messages\",\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID\"},\"contacts\":[{\"wa_id\":\"$OWNER\",\"profile\":{\"name\":\"Alejandro Orozco Flores\"}}],\"messages\":[{\"from\":\"$OWNER\",\"id\":\"$W2\",\"timestamp\":\"$(ts)\",\"type\":\"text\",\"text\":{\"body\":\"$QTXT\"}}]}}]}]}" +echo " http=$(post_webhook "$BODY2")" +sleep 6 +chk "PASO3 estado=propuesta" "$(state)" "propuesta" +chk "PASO2 captura dolor" "$(psql_q "SELECT custom_fields->>'ia360_revenue_dolor' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$OWNER'")" "$QTXT" +echo " señal: canal=$(psql_q "SELECT custom_fields->>'ia360_revenue_canal' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$OWNER'") volumen=$(psql_q "SELECT custom_fields->>'ia360_revenue_volumen' FROM coexistence.contacts WHERE wa_number='$WA' AND contact_number='$OWNER'")" +PASO3_BODY="$(latest_out_label ia360_os_revenue_paso3)" +echo " paso3 saliente: $PASO3_BODY" +chk_has "PASO3 propuesta enviada" "$PASO3_BODY" "Revenue OS" +chk_has "PASO3 incluye botones" "$PASO3_BODY" "Ver cómo se vería" +# GUARDRAIL anti-doble-respuesta: el gate debe cortar al agente genérico. Aserción que +# MUERDE (no enmascarada por dedup): NINGUNA saliente con label ia360_ai_* para ese inbound. +AGENT_REPLIES="$(psql_q "SELECT count(*) FROM coexistence.chat_history WHERE direction='outgoing' AND contact_number='$OWNER' AND template_meta->>'ia360_handler_for'='$W2' AND template_meta->>'label' LIKE 'ia360_ai_%'")" +chk "PASO2 sin respuesta del agente (gate cortó el embudo)" "$AGENT_REPLIES" "0" +chk "PASO2 exactamente 1 saliente (la propuesta)" "$(out_count_handler "$W2")" "1" + +echo "=== PASO 3 rama A — 'Ver cómo se vería' (demo + mover stage) ===" +W3="$(wamid demo)" +BODY3="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"WABA\",\"changes\":[{\"field\":\"messages\",\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID\"},\"contacts\":[{\"wa_id\":\"$OWNER\",\"profile\":{\"name\":\"Alejandro Orozco Flores\"}}],\"messages\":[{\"from\":\"$OWNER\",\"id\":\"$W3\",\"timestamp\":\"$(ts)\",\"type\":\"interactive\",\"interactive\":{\"type\":\"button_reply\",\"button_reply\":{\"id\":\"revenue_ver_demo\",\"title\":\"Ver cómo se vería\"}}}]}}]}]}" +echo " http=$(post_webhook "$BODY3")" +sleep 5 +chk "RAMA-A estado=demo" "$(state)" "demo" +chk "RAMA-A deal movido a 'Diseño propuesto'" "$(p5_stage)" "Diseño propuesto" +DEMO_BODY="$(latest_out_label ia360_os_revenue_demo)" +echo " demo saliente: $DEMO_BODY" +chk_has "RAMA-A readout/mini-demo enviado" "$DEMO_BODY" "Revenue OS" + +echo "=== reset estado a 'propuesta' para probar rama B ===" +psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields || '{\"ia360_revenue_state\":\"propuesta\"}'::jsonb WHERE wa_number='$WA' AND contact_number='$OWNER'" >/dev/null +chk "reset ok" "$(state)" "propuesta" + +echo "=== PASO 3 rama B — 'Hablar con Alek' (handoff a compuerta de agenda) ===" +W4="$(wamid handoff)" +BODY4="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"WABA\",\"changes\":[{\"field\":\"messages\",\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID\"},\"contacts\":[{\"wa_id\":\"$OWNER\",\"profile\":{\"name\":\"Alejandro Orozco Flores\"}}],\"messages\":[{\"from\":\"$OWNER\",\"id\":\"$W4\",\"timestamp\":\"$(ts)\",\"type\":\"interactive\",\"interactive\":{\"type\":\"button_reply\",\"button_reply\":{\"id\":\"revenue_hablar_alek\",\"title\":\"Hablar con Alek\"}}}]}}]}]}" +echo " http=$(post_webhook "$BODY4")" +sleep 5 +chk "RAMA-B estado=handoff" "$(state)" "handoff" +GATE_BODY="$(latest_out_label ia360_os_revenue_gate_agenda)" +echo " gate saliente: $GATE_BODY" +chk_has "RAMA-B compuerta de agenda (NO offer_slots directo)" "$GATE_BODY" "horarios para una llamada" +chk_has "RAMA-B botones gate" "$GATE_BODY" "Sí, ver horarios" + +echo "=== rama 'Ahora no' — reset a apertura_sent y probar cierre+nutrición ===" +psql_q "UPDATE coexistence.contacts SET custom_fields = custom_fields || '{\"ia360_revenue_state\":\"apertura_sent\"}'::jsonb WHERE wa_number='$WA' AND contact_number='$OWNER'" >/dev/null +W5="$(wamid no)" +BODY5="{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"WABA\",\"changes\":[{\"field\":\"messages\",\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"$WA\",\"phone_number_id\":\"$PID\"},\"contacts\":[{\"wa_id\":\"$OWNER\",\"profile\":{\"name\":\"Alejandro Orozco Flores\"}}],\"messages\":[{\"from\":\"$OWNER\",\"id\":\"$W5\",\"timestamp\":\"$(ts)\",\"type\":\"button\",\"button\":{\"text\":\"Ahora no\",\"payload\":\"Ahora no\"}}]}}]}]}" +echo " http=$(post_webhook "$BODY5")" +sleep 5 +chk "AHORA-NO estado=nutricion" "$(state)" "nutricion" +chk "AHORA-NO tag nutricion-suave" "$(has_tag nutricion-suave)" "t" +NO_BODY="$(latest_out_label ia360_os_revenue_ahora_no)" +echo " cierre saliente: $NO_BODY" +chk_has "AHORA-NO cierre cordial enviado" "$NO_BODY" "Te dejo el espacio" +chk "AHORA-NO sin insistir (1 saliente para ese inbound)" "$(out_count_handler "$W5")" "1" + +echo "" +echo "============================================================" +echo " RESULTADO E2E Revenue OS: PASS=$PASS FAIL=$FAIL" +echo "============================================================" +[ "$FAIL" -eq 0 ] && echo " >>> 8/8 OK <<<" || echo " >>> revisar FAILs arriba <<<" +exit 0 diff --git a/scripts/ia360_full_funnel_dry_run.py b/scripts/ia360_full_funnel_dry_run.py new file mode 100644 index 0000000..9625408 --- /dev/null +++ b/scripts/ia360_full_funnel_dry_run.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""IA360 full-funnel dry-run classifier. + +Read-only diagnostic: pulls existing Alek WhatsApp/email events and classifies +what n8n/pipeline agents SHOULD do. It does not write to any database, send +messages, create contacts, or update pipelines. +""" +import json +import re +import subprocess +from dataclasses import dataclass, asdict +from typing import List + +CANONICAL = { + "forgechat_contact_id": 2, + "whatsapp_contact_number": "5213322638033", + "whatsapp_wa_number": "5213321594582", + "espocrm_contact_id": "6a1e395e3ea030531", + "email": "Ocompudoc@gmail.com", +} + +NEGATIVE_PATTERNS = [ + r"pendej", r"basta de pruebas", r"pruebas sueltas", r"no me sirve", + r"no.*objetivo", r"cagadero", r"deja de", r"mal plantead", +] +HIGH_INTENT_PATTERNS = [ + r"ventas", r"prospecci[oó]n", r"empresarios", r"alto nivel", r"contratar", + r"implementar", r"aplicar", r"costo", r"propuesta", r"agenda", r"reuni[oó]n", +] +PAIN_PATTERNS = { + "ventas_prospeccion_alto_nivel": [r"ventas", r"prospecci[oó]n", r"prospectos", r"empresarios", r"alto nivel"], + "whatsapp_crm_pipeline": [r"whatsapp", r"crm", r"pipeline", r"embudo"], + "erp_bi_operativo": [r"erp", r"bi", r"reportes", r"datapower", r"dashboards"], + "agentic_workflows": [r"agente", r"agentes", r"n8n", r"automatiz", r"clasific"], +} + +@dataclass +class Classification: + source: str + event_id: str + subject_or_type: str + text_excerpt: str + event_type: str + intent: str + pain_tags: List[str] + temperature: str + forgechat_stage: str + espo_stage: str + should_create_task: bool + should_create_or_update_opportunity: bool + should_send_auto_reply: bool + next_action: str + reason: str + + +def sh(cmd: str) -> str: + return subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL) + + +def sh_stdin(cmd: str, data: str) -> str: + return subprocess.run( + cmd, + input=data, + shell=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + ).stdout + + +def norm(s: str) -> str: + return (s or "").lower() + + +def has_any(text: str, patterns: List[str]) -> bool: + t = norm(text) + return any(re.search(p, t) for p in patterns) + + +def detect_pains(text: str) -> List[str]: + t = norm(text) + tags = [] + for tag, patterns in PAIN_PATTERNS.items(): + if any(re.search(p, t) for p in patterns): + tags.append(tag) + return tags + + +def classify(source: str, event_id: str, subject_or_type: str, text: str) -> Classification: + excerpt = " ".join((text or "").split())[:220] + pains = detect_pains(text) + if has_any(text, NEGATIVE_PATTERNS): + return Classification( + source, event_id, subject_or_type, excerpt, + "negative_feedback", "human_review", pains or ["test_process_failure"], "high", + "Requiere Alek", "Qualification", True, False, False, + "pause_automation_and_create_human_review_task", + "Frustrated/negative feedback must stop automated branch and trigger human review.", + ) + if has_any(text, HIGH_INTENT_PATTERNS): + return Classification( + source, event_id, subject_or_type, excerpt, + "reply_high_intent", "qualified_interest", pains, "hot" if pains else "warm", + "Requiere Alek", "Qualification", True, True, False, + "create_or_update_opportunity_and_human_followup_task", + "Reply contains business objective/interest; should not stay archived/passive.", + ) + return Classification( + source, event_id, subject_or_type, excerpt, + "reply_unclassified", "unknown", pains, "cold", + "Nutrición", "Prospecting", False, False, False, + "log_note_only_or_nurture", "No high-intent or negative signal detected.", + ) + + +def get_latest_email_reply() -> tuple: + sql = """ +select id, name, coalesce(body_plain, body, '') +from email +where from_string like '%Ocompudoc%' + and parent_id='6a1e395e3ea030531' +order by created_at desc +limit 1; +""".strip() + cmd = "docker exec -i espocrm-db sh -lc 'mariadb -uroot -p\"$MARIADB_ROOT_PASSWORD\" \"$MARIADB_DATABASE\" -N'" + out = sh_stdin(cmd, sql).strip() + if not out: + return None + parts = out.split("\t", 2) + return parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else "" + + +def get_latest_whatsapp_incoming() -> tuple: + sql = """ +select id::text, message_type, coalesce(message_body,'') +from coexistence.chat_history +where contact_number='5213322638033' and direction='incoming' +order by timestamp desc +limit 1; +""".strip() + cmd = "docker exec -i forgecrm-db psql -U postgres -d postgres -Atq" + out = sh_stdin(cmd, sql).strip() + if not out: + return None + parts = out.split("|", 2) + return parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else "" + + +def main(): + events = [] + email = get_latest_email_reply() + if email: + events.append(classify("email", email[0], email[1], email[2])) + wa = get_latest_whatsapp_incoming() + if wa: + events.append(classify("whatsapp", wa[0], wa[1], wa[2])) + print(json.dumps({ + "mode": "dry_run_read_only", + "canonical": CANONICAL, + "events_classified": [asdict(e) for e in events], + }, ensure_ascii=False, indent=2)) + +if __name__ == "__main__": + main() From 9e0256f663239f04155e39c8dd504a9d3d39debc Mon Sep 17 00:00:00 2001 From: Alek Zen Date: Tue, 16 Jun 2026 14:10:11 +0000 Subject: [PATCH 39/39] =?UTF-8?q?docs(runbook):=20G10=20desplegado=20+=20P?= =?UTF-8?q?1/P2=20estado=20actualizado=20(P1=20verificado,=20P2=20c=C3=B3d?= =?UTF-8?q?igo=20hecho,=20falta=20templates=20Meta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/AGENTS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 44cb6e3..f4159b1 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -79,15 +79,16 @@ DBURL=$(grep -E '^DATABASE_URL=' ~/stack/forgechat-poc/backend/.env | cut -d= -f docker exec -i forgecrm-db psql "$DBURL" -t -A -F'|' < /tmp/q.sql ``` -## Estado del trabajo — cierre 2026-06-15 (DESPLEGADO en producción, main) +## Estado del trabajo — cierre 2026-06-16 (DESPLEGADO en producción, main) +- **G10** P2 stages: `IA360_PARTNER_STAGE_MAP` ahora alcanza `Fit identificado` (pos 0) y `Diagnóstico compartido` (pos 3). Callsite Fit en `handleIa360OwnerSequenceChoice` (pre-envío, partner): cuando el owner elige secuencia para un aliado/referido, su deal nace en pos 0. Secuencias Blueprint/Propuesta = **stub inerte detrás de flag `IA360_PARTNER_BLUEPRINT` (default OFF)** con predicado `ia360PartnerBlueprintSendable` fail-closed (sin template aprobado → no envía; no hay ruta de envío todavía). Módulo `ia360DealRouting.js`, test `ia360PartnerBlueprintStages.test.js` (9/9). Bugfix `d8f68ee`: el callsite Fit re-scopea `record` a `targetContact` (si no, el deal se creaba bajo el número del owner — lo cazó la prueba viva). Verificado en vivo: deal del aliado en pos 0, y guard de no-regresión (deal avanzado no vuelve a pos 0). - **G6** botones de opener ("Sí, cuéntame"→Revenue OS paso2 / "Ahora no"→cierre). - **G5** circuit breaker de pago: detecta status `failed` 131042 en el webhook, alerta al owner (anti-spam), `skipRetry` en sendQueue; gate de pausa NO activado (deadlock). - **G8** ruteo: deals con `relationshipContext` `aliado_socio`/`referido_bni` → pipeline "Partners / Aliados (BNI)" (id 6), no al genérico. `syncIa360Deal(pipelineName=...)`, módulo `ia360DealRouting.js`. - **G9** comando owner `intro : ` (atajo `referido de `) puebla `quien_intro` → desbloquea la secuencia de referido. Módulo `ia360ReferidoIntro.js`. ## Pendientes (backlog) -- **P1 (test vivo):** Alek teclea `intro 5210000002102: ` desde su WhatsApp → verificar `quien_intro` en `coexistence.contacts`. -- **P2 (journey aliado):** faltan etapas **Blueprint** y **Propuesta** (templates nuevos en Meta + secuencias). Stages del pipeline 6 `Fit identificado` (pos 0) y `Diagnóstico compartido` (pos 3) no los setea ningún callsite. +- **P1 (test vivo): ✅ HECHO 2026-06-16.** Verificado end-to-end por inyección de webhook firmado del owner: `quien_intro` se puebla en `coexistence.contacts`, y el efecto downstream queda probado con las funciones de producción — el gate COLD (`cold_send_missing_quien_intro`) y el HOT (`copy_blocked`, placeholder en draft) pasan de BLOCKED→CLEAR. Falta solo, si se quiere, que Alek lo teclee desde su WhatsApp real (la lógica ya está verificada). +- **P2 (journey aliado): código HECHO (G10), falta lo externo.** Stages `Fit identificado`/`Diagnóstico compartido` ya cableados + stub Blueprint/Propuesta fail-closed desplegado. **Pendiente NO-código:** crear y aprobar en Meta los templates `ia360_partner_blueprint` y `ia360_partner_propuesta`, luego activar `IA360_PARTNER_BLUEPRINT=on` y cablear la ruta de envío real (que DEBE pasar por los gates `outside_window_template_not_approved`/`cold_template_status_check_failed`). Sin esos templates no se ofrece ni envía nada (por diseño). - **DEFERRED (optimización, NO ahora):** latencia del cerebro Brain v2 (nodo responder gpt-5 ~27s) se corta por timeout 30s de `callIa360Agent` (webhook.js ~L2669). Opción: async sin timeout. Nota: Hermes edita mensajes porque usa **Baileys/WhatsApp-Web** (`/opt/hermes-agent/.../whatsapp-bridge/bridge.js`), no la Cloud API; la Cloud API oficial NO edita salientes. - **BLOQUEO push:** `git push` del backend falla **403** (usuario `AlekZen` sin permiso a `Forgemind-git/ForgeChat`). Los commits están en `main` local del VPS; resolver permiso o pushear con la cuenta dueña.

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 1 ? `${parts[parts.length - 1]} WhatsApp IA360` : 'WhatsApp IA360';\nconst contactRes = await upsertByName.call(this, 'Contact', contactName, { firstName, lastName, assignedUserId, assignedUserName, description: descriptionBase + '\\n\\nNota: WA se guarda en descripción para evitar rechazo por validación estricta de phoneNumber en EspoCRM.' });\nlet opportunityRes = null;\nif (espoStage) {\n const closeDate = new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10);\n opportunityRes = await upsertByName.call(this, 'Opportunity', opportunityName, { name: opportunityName, stage: espoStage, amount: 0, closeDate, assignedUserId, assignedUserName, description: descriptionBase });\n}\nlet taskRes = null;\nif (createTask) {\n const due = new Date(Date.now() + (priority === 'High' ? 2 : 24) * 3600000).toISOString().slice(0, 19).replace('T', ' ');\n const nextAction = eventType === 'meeting_confirmed_calendar_zoom'\n ? 'Preparar briefing humano para reunión confirmada; Calendar/Zoom ya existen.'\n : eventType === 'call_requested' || eventType === 'agenda_preference_selected'\n ? 'Revisar contexto en ForgeChat y confirmar horario real; no contar como reunión confirmada hasta crear evento.'\n : eventType === 'proposal_requested'\n ? 'Preparar alcance/costo con base en contexto y oferta sugerida.'\n : 'Revisar contexto y definir siguiente acción humana.';\n taskRes = await upsertByName.call(this, 'Task', taskName, { name: taskName, status: 'Not Started', priority, dateEnd: due, assignedUserId, assignedUserName, description: descriptionBase + `\\n\\nSiguiente acción humana: ${nextAction}` });\n}\nreturn [{ json: { ok: true, source: 'n8n-ia360-handoff-code-httpRequest', eventType, wa, mapping: { targetStage, espoStage, createTask, offer }, espocrm: { account: { action: accountRes.action, id: accountRes.record.id, name: accountRes.record.name }, contact: { action: contactRes.action, id: contactRes.record.id, name: contactRes.record.name }, opportunity: opportunityRes ? { action: opportunityRes.action, id: opportunityRes.record.id, name: opportunityRes.record.name, stage: opportunityRes.record.stage } : null, task: taskRes ? { action: taskRes.action, id: taskRes.record.id, name: taskRes.record.name, priority: taskRes.record.priority, status: taskRes.record.status } : null } } }];\n" + }, + "id": "Code_EspoCRM_Sync", + "name": "Code EspoCRM Sync", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -300, + 0 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "id": "Respond_OK", + "name": "Respond OK", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 20, + 0 + ] + } + ], + "connections": { + "Webhook IA360 Handoff": { + "main": [ + [ + { + "node": "Code EspoCRM Sync", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code EspoCRM Sync": { + "main": [ + [ + { + "node": "Respond OK", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "staticData": null, + "meta": { + "templateCredsSetupCompleted": true + }, + "pinData": {}, + "versionId": "code-httpRequest-ia360-espo-sync-2026-06-02", + "tags": [ + { + "name": "IA360" + }, + { + "name": "WhatsApp" + }, + { + "name": "EspoCRM" + }, + { + "name": "Active" + } + ] +} \ No newline at end of file diff --git a/n8n-ia360-whatsapp-handoff-draft.workflow.json b/n8n-ia360-whatsapp-handoff-draft.workflow.json new file mode 100644 index 0000000..56a0e74 --- /dev/null +++ b/n8n-ia360-whatsapp-handoff-draft.workflow.json @@ -0,0 +1,265 @@ +{ + "name": "IA360 WhatsApp Handoff → EspoCRM + Calendar Zoom Draft", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "ia360-whatsapp-handoff", + "responseMode": "responseNode", + "options": {} + }, + "id": "Webhook_IA360_Handoff", + "name": "Webhook IA360 Handoff", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + -760, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "source-check", + "leftValue": "={{$json.body.source}}", + "rightValue": "forgechat-ia360-webhook", + "operator": { + "type": "string", + "operation": "equals" + } + }, + { + "id": "phone-check", + "leftValue": "={{$json.body.contact.contactNumber}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty" + } + }, + { + "id": "event-check", + "leftValue": "={{$json.body.eventType}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "Validate_Payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + -540, + 0 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "eventType", + "name": "eventType", + "value": "={{$json.body.eventType}}", + "type": "string" + }, + { + "id": "priority", + "name": "priority", + "value": "={{$json.body.priority || 'normal'}}", + "type": "string" + }, + { + "id": "phone", + "name": "phone", + "value": "={{$json.body.contact.contactNumber}}", + "type": "string" + }, + { + "id": "name", + "name": "name", + "value": "={{$json.body.contact.contactName || $json.body.contact.contactNumber}}", + "type": "string" + }, + { + "id": "summary", + "name": "summary", + "value": "={{$json.body.summary}}", + "type": "string" + }, + { + "id": "triggerBody", + "name": "triggerBody", + "value": "={{$json.body.trigger.messageBody}}", + "type": "string" + }, + { + "id": "targetStage", + "name": "targetStage", + "value": "={{$json.body.targetStage}}", + "type": "string" + }, + { + "id": "taskDescription", + "name": "taskDescription", + "value": "={{'IA360 WhatsApp handoff\\n\\nEvento: ' + $json.body.eventType + '\\nPrioridad: ' + ($json.body.priority || 'normal') + '\\nStage: ' + $json.body.targetStage + '\\nContacto: ' + ($json.body.contact.contactName || '') + ' / ' + $json.body.contact.contactNumber + '\\nÚltima respuesta: ' + $json.body.trigger.messageBody + '\\n\\nResumen: ' + $json.body.summary + '\\n\\nAcciones recomendadas: ' + ($json.body.recommendedActions || []).join(', ')}}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "Normalize_Context", + "name": "Normalize Lead Context", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -300, + -120 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{$env.ESPOCRM_URL + '/api/v1/Task'}}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "httpHeaderAuth", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { name: 'IA360 WhatsApp handoff: ' + $json.eventType, status: 'Not Started', priority: $json.priority === 'high' ? 'High' : 'Normal', description: $json.taskDescription } }}", + "options": {} + }, + "id": "Create_EspoCRM_Task", + "name": "Create EspoCRM Task (configure credential)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -40, + -120 + ], + "disabled": true, + "notes": "Enable after configuring EspoCRM URL/API credential. Upsert contact should be added before this node once final field mapping is confirmed." + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { ok: true, received: true, eventType: $json.eventType, phone: $json.phone, next: 'configure EspoCRM/Calendar/Zoom credentials' } }}", + "options": {} + }, + "id": "Respond_OK", + "name": "Respond OK", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 240, + -120 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { ok: false, error: 'Invalid IA360 handoff payload' } }}", + "options": { + "responseCode": 400 + } + }, + "id": "Respond_Bad_Request", + "name": "Respond Bad Request", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + -300, + 160 + ] + } + ], + "connections": { + "Webhook IA360 Handoff": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Normalize Lead Context", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond Bad Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Lead Context": { + "main": [ + [ + { + "node": "Create EspoCRM Task (configure credential)", + "type": "main", + "index": 0 + }, + { + "node": "Respond OK", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create EspoCRM Task (configure credential)": { + "main": [ + [] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "staticData": null, + "meta": { + "templateCredsSetupCompleted": false + }, + "pinData": {}, + "versionId": "draft-ia360-whatsapp-handoff-2026-06-01", + "triggerCount": 0, + "tags": [ + { + "name": "IA360" + }, + { + "name": "WhatsApp" + }, + { + "name": "Draft" + } + ], + "id": "IA360WhatsAppHandoffDraft20260601", + "active": false +} \ No newline at end of file diff --git a/out/create-ia360-email-reply-router-dry-run.sql b/out/create-ia360-email-reply-router-dry-run.sql new file mode 100644 index 0000000..c439cf9 --- /dev/null +++ b/out/create-ia360-email-reply-router-dry-run.sql @@ -0,0 +1,53 @@ +INSERT INTO workflow_entity ( + name, active, nodes, connections, "createdAt", "updatedAt", settings, + "staticData", "pinData", "versionId", "triggerCount", id, meta, + "parentFolderId", "isArchived", "versionCounter", description, "activeVersionId", "nodeGroups" +) +VALUES ( + 'IA360 Email Reply Router — Dry Run', + false, + '[ + { + "parameters": {"httpMethod": "POST", "path": "ia360-email-reply-router-dry-run", "responseMode": "responseNode", "options": {}}, + "id": "Webhook_IA360_Email_Reply_Dry_Run", + "name": "Webhook Email Reply Dry Run", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [-620, 0] + }, + { + "parameters": {"mode": "runOnceForAllItems", "jsCode": "const input = $input.first().json.body || $input.first().json;\nconst text = String(input.text || input.body_plain || input.body || input.reply_text || '''').toLowerCase();\nconst subject = input.subject || input.name || input.subject_or_type || '''';\nconst eventId = input.event_id || input.email_id || input.id || '''';\nfunction has(patterns) { return patterns.some(p => new RegExp(p, ''i'').test(text)); }\nconst negative = has([''pendej'', ''basta de pruebas'', ''pruebas sueltas'', ''no me sirve'', ''cagadero'', ''deja de'']);\nconst highIntent = has([''ventas'', ''prospecci[oó]n'', ''empresarios'', ''alto nivel'', ''contratar'', ''implementar'', ''aplicar'', ''costo'', ''propuesta'', ''agenda'', ''reuni[oó]n'']);\nconst painTags = [];\nif (has([''ventas'', ''prospecci[oó]n'', ''prospectos'', ''empresarios'', ''alto nivel''])) painTags.push(''ventas_prospeccion_alto_nivel'');\nif (has([''whatsapp'', ''crm'', ''pipeline'', ''embudo''])) painTags.push(''whatsapp_crm_pipeline'');\nif (has([''erp'', ''bi'', ''reportes'', ''datapower'', ''dashboards''])) painTags.push(''erp_bi_operativo'');\nif (has([''agente'', ''agentes'', ''n8n'', ''automatiz'', ''clasific''])) painTags.push(''agentic_workflows'');\nlet decision;\nif (negative) {\n decision = { event_type: ''negative_feedback'', intent: ''human_review'', temperature: ''high'', forgechat_stage: ''Requiere Alek'', espo_stage: ''Qualification'', should_create_task: true, should_create_or_update_opportunity: false, should_send_auto_reply: false, next_action: ''pause_automation_and_create_human_review_task'' };\n} else if (highIntent) {\n decision = { event_type: ''reply_high_intent'', intent: ''qualified_interest'', temperature: painTags.length ? ''hot'' : ''warm'', forgechat_stage: ''Requiere Alek'', espo_stage: ''Qualification'', should_create_task: true, should_create_or_update_opportunity: true, should_send_auto_reply: false, next_action: ''create_or_update_opportunity_and_human_followup_task'' };\n} else {\n decision = { event_type: ''reply_unclassified'', intent: ''unknown'', temperature: ''cold'', forgechat_stage: ''Nutrición'', espo_stage: ''Prospecting'', should_create_task: false, should_create_or_update_opportunity: false, should_send_auto_reply: false, next_action: ''log_note_only_or_nurture'' };\n}\nreturn [{ json: { mode: ''dry_run_no_writes'', source: ''email'', event_id: eventId, subject, text_excerpt: String(input.text || input.body_plain || input.body || '''').replace(/\\s+/g, '' '').slice(0, 220), pain_tags: painTags, decision } }];"}, + "id": "Code_Classify_Email_Reply_Dry_Run", + "name": "Classify Email Reply Dry Run", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-300, 0] + }, + { + "parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}, + "id": "Respond_Dry_Run", + "name": "Respond Dry Run", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [20, 0] + } + ]'::json, + '{"Webhook Email Reply Dry Run":{"main":[[{"node":"Classify Email Reply Dry Run","type":"main","index":0}]]},"Classify Email Reply Dry Run":{"main":[[{"node":"Respond Dry Run","type":"main","index":0}]]}}'::json, + now(), now(), '{}'::json, '{}'::json, '{}'::json, + 'ia360-email-reply-router-dry-run-v1', 0, + 'IA360EmailReplyRouterDryRun20260602', + '{"templateCredsSetupCompleted":true}'::json, + NULL, false, 1, + 'Dry-run only. Classifies email replies into IA360 pipeline decisions. No CRM writes, no sends, inactive until explicitly approved.', + NULL, '[]'::json +) +ON CONFLICT (id) DO UPDATE SET + name=excluded.name, + active=false, + nodes=excluded.nodes, + connections=excluded.connections, + "updatedAt"=now(), + description=excluded.description, + "nodeGroups"='[]'::json; + +select id||'|'||name||'|'||active||'|'||description from workflow_entity where id='IA360EmailReplyRouterDryRun20260602'; diff --git a/out/dedupe-alek-forgechat.sql b/out/dedupe-alek-forgechat.sql new file mode 100644 index 0000000..56038fb --- /dev/null +++ b/out/dedupe-alek-forgechat.sql @@ -0,0 +1,28 @@ +BEGIN; +-- Canonical ForgeChat Alek contact: id=2, wa_number=5213321594582, contact_number=5213322638033. +-- Duplicate to remove: id=157, fake wa_number=5210000000000. + +UPDATE coexistence.contacts c +SET tags = ( + SELECT jsonb_agg(DISTINCT elem) + FROM jsonb_array_elements(COALESCE(c.tags,'[]'::jsonb) || COALESCE(d.tags,'[]'::jsonb)) elem + ), + custom_fields = COALESCE(c.custom_fields,'{}'::jsonb) || COALESCE(d.custom_fields,'{}'::jsonb) || jsonb_build_object( + 'dedupe_canonical', 'true', + 'dedupe_merged_contact_ids', '157', + 'dedupe_merged_at', '2026-06-02T15:23:00Z', + 'dedupe_reason', 'Same Alek WhatsApp number; remove fake wa_number duplicate.' + ), + name = 'Soy Alek', + profile_name = 'Soy Alek', + updated_at = now() +FROM coexistence.contacts d +WHERE c.id=2 AND d.id=157; + +DELETE FROM coexistence.contacts WHERE id=157; + +SELECT id||'|'||wa_number||'|'||contact_number||'|'||coalesce(name,'')||'|'||coalesce(profile_name,'')||'|'||coalesce(custom_fields->>'dedupe_merged_contact_ids','') +FROM coexistence.contacts +WHERE contact_number='5213322638033' +ORDER BY id; +COMMIT; diff --git a/out/dedupe-backups/forgechat-alek-contacts-20260602T152324Z.sql b/out/dedupe-backups/forgechat-alek-contacts-20260602T152324Z.sql new file mode 100644 index 0000000..e69de29 diff --git a/out/dedupe-backups/forgechat-alek-contacts-20260602T152337Z.csv b/out/dedupe-backups/forgechat-alek-contacts-20260602T152337Z.csv new file mode 100644 index 0000000..91588da --- /dev/null +++ b/out/dedupe-backups/forgechat-alek-contacts-20260602T152337Z.csv @@ -0,0 +1,3 @@ +id,wa_number,contact_number,name,created_at,updated_at,tags,custom_fields,assigned_user_id,profile_name +157,5210000000000,5213322638033,,2026-06-02 01:58:19.564732+00,2026-06-02 15:09:18.246221+00,"[""campana-ia360"", ""hot-lead"", ""requiere-alek"", ""reunion-solicitada""]","{""email"": ""Ocompudoc@gmail.com"", ""tipo_contacto"": ""internal_test_owner"", ""nombre_canonico"": ""Soy Alek"", ""proximo_followup"": ""Elegir hora disponible para: Mañana"", ""ultimo_cta_enviado"": ""ia360_lite_agenda_slots"", ""ia360_ultima_respuesta"": ""Mañana""}",,Soy Alek AI +2,5213321594582,5213322638033,Soy Alek,2026-06-01 19:41:55.147517+00,2026-06-02 15:19:54.084284+00,"[""campana-ia360"", ""detalle-solicitado"", ""diagnostico-enviado"", ""dolor-captura-manual"", ""ejemplo-solicitado"", ""flujo-solicitado"", ""hot-lead"", ""intencion-detectada"", ""interes-datapower"", ""interes-erp-integraciones"", ""interes-og4-diagnostico"", ""interes-whatsapp-business"", ""mapa-30-60-90-solicitado"", ""mecanismo-whatsapp-crm"", ""origen-whatsapp"", ""problema-reconocido"", ""prospecting-100m"", ""requiere-alek"", ""respondio-diagnostico"", ""reunion-confirmada"", ""reunion-solicitada"", ""zoom-creado"", ""feedback-negativo"", ""requiere-alek"", ""pausar-automatizacion""]","{""rol"": ""Owner GeekStudio / IA360"", ""email"": ""Ocompudoc@gmail.com"", ""area_dolor"": ""Ventas"", ""campana_ia360"": ""IA360 100M WhatsApp prospecting"", ""fuente_origen"": ""whatsapp-template-100m"", ""tipo_contacto"": ""internal_test_owner"", ""dolor_principal"": ""Trabajo manual / doble captura en WhatsApp"", ""nombre_canonico"": ""Soy Alek"", ""score_intencion"": ""test"", ""proximo_followup"": ""Reunión confirmada: 2026-06-02T21:00:00.000Z"", ""ultimo_cta_enviado"": ""ia360_lite_reunion_confirmada"", ""servicio_recomendado"": ""WhatsApp Revenue OS / CRM conversacional"", ""ia360_stage_guardrail"": ""Requiere Alek"", ""ia360_ultima_respuesta"": ""15:00"", ""ia360_feedback_negativo"": ""true"", ""identificado_por_hermes"": ""2026-06-02"", ""ia360_feedback_negativo_at"": ""2026-06-02T15:02:02Z"", ""ia360_feedback_negativo_resumen"": ""Alek pidió dejar de hacer pruebas sueltas y validar/simular embudos reales.""}",,Soy Alek diff --git a/out/dedupe-backups/forgechat-alek-deals-20260602T152337Z.csv b/out/dedupe-backups/forgechat-alek-deals-20260602T152337Z.csv new file mode 100644 index 0000000..3ca5f60 --- /dev/null +++ b/out/dedupe-backups/forgechat-alek-deals-20260602T152337Z.csv @@ -0,0 +1,57 @@ +id,pipeline_id,stage_id,title,value,currency,status,assigned_user_id,contact_wa_number,contact_number,contact_name,expected_close_date,notes,position,won_at,lost_at,created_by,created_at,updated_at +2,2,18,IA360 · Soy Alek AI · Reunión confirmada,0.00,MXN,open,1,5213321594582,5213322638033,Soy Alek AI,,"[2026-06-01T20:53:16.617Z] Input: Diagnóstico; intención inicial detectada +[2026-06-01T20:53:17.423Z] Área de dolor seleccionada: ERP / CRM +[2026-06-01T20:53:17.974Z] Solicitó diagnóstico ligero de 5 preguntas +[2026-06-01T20:53:18.554Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T20:53:19.192Z] Solicitó ejemplo IA360 +[2026-06-01T20:53:19.823Z] Pregunta 1/5: trabajo manual o doble captura en WhatsApp +[2026-06-01T20:53:20.306Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T20:53:20.834Z] Solicitó ejemplo ERP → BI +[2026-06-01T20:53:21.535Z] Solicitó Ver flujo del ejemplo WhatsApp → CRM +[2026-06-01T20:54:48.133Z] Área de dolor seleccionada: Ventas +[2026-06-01T20:55:01.474Z] Solicitó ejemplo IA360 +[2026-06-01T20:55:10.702Z] Solicitó ejemplo WhatsApp → CRM; listo para propuesta/siguiente paso +[2026-06-01T20:55:15.782Z] Solicitó Ver flujo del ejemplo WhatsApp → CRM +[2026-06-01T20:58:09.366Z] Solicitó Ver flujo del ejemplo WhatsApp → CRM +[2026-06-01T20:58:15.912Z] Solicitó detalle: Arquitectura +[2026-06-01T20:58:17.249Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T20:58:26.065Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T20:58:30.901Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T20:58:35.928Z] Solicitó detalle: Costo +[2026-06-01T20:58:40.620Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T20:58:49.287Z] Solicitó detalle: Costo +[2026-06-01T20:58:59.188Z] Solicitó detalle: Alcance +[2026-06-01T21:00:29.112Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T21:01:20.546Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T21:01:21.867Z] Solicitó detalle: Alcance +[2026-06-01T21:01:26.869Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T21:01:28.292Z] Solicitó detalle: Alcance +[2026-06-01T21:03:25.516Z] Solicitó detalle: Alcance +[2026-06-01T21:03:35.058Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T21:05:16.506Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T21:27:10.193Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-01T21:30:18.486Z] 100M flow: Diagnóstico rápido → Intención detectada +[2026-06-01T21:30:24.771Z] 100M flow: Captura manual → Dolor calificado +[2026-06-01T21:30:28.660Z] 100M flow: WhatsApp → CRM → Propuesta / siguiente paso +[2026-06-01T21:30:34.232Z] 100M flow: Quiero mapa → Diagnóstico enviado +[2026-06-01T21:30:39.816Z] 100M flow: Sí, urgente → Requiere Alek +[2026-06-01T21:30:44.294Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-01T21:30:48.626Z] Solicitó detalle: Alcance +[2026-06-01T21:30:52.503Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T21:30:59.702Z] Solicitó detalle: Costo +[2026-06-01T21:31:04.317Z] Solicitó Aplicarlo del ejemplo WhatsApp → CRM +[2026-06-01T21:31:09.407Z] Solicitó detalle: Alcance +[2026-06-01T21:33:33.368Z] Solicitó detalle: Alcance +[2026-06-01T21:34:18.962Z] Solicitó detalle: Alcance +[2026-06-01T21:34:40.556Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T21:34:46.485Z] Preferencia de agenda seleccionada: Mañana; falta crear evento real +[2026-06-01T21:42:04.310Z] Solicitó detalle: Alcance +[2026-06-01T22:23:07.937Z] Preferencia de agenda seleccionada: Mañana; falta crear evento real +[2026-06-01T22:24:38.188Z] Solicitó detalle: Alcance +[2026-06-01T22:24:43.996Z] Solicitó agendar: requiere intervención de Alek +[2026-06-01T22:24:48.366Z] Preferencia de agenda seleccionada: Hoy; falta crear evento real +[2026-06-02T00:18:19.045Z] Preferencia de agenda seleccionada: Mañana; falta crear evento real +[2026-06-02T02:00:57.325Z] Reunión confirmada. Calendar event: duk3hiv3tecn3vkco5bb9v5ae0; Zoom meeting: 83902405403; inicio: 2026-06-02T21:00:00.000Z",0,,,1,2026-06-01 20:53:16.663193+00,2026-06-02 02:00:57.345113+00 +3,2,18,IA360 · Soy Alek AI · Agenda Mañana,0.00,MXN,open,1,5210000000000,5213322638033,Soy Alek AI,,"[2026-06-02T01:58:19.620Z] Preferencia de agenda seleccionada: Mañana; falta crear evento real +[2026-06-02T01:59:08.404Z] Preferencia de día seleccionada: Mañana; se consultó disponibilidad real de Calendar para ofrecer horas libres +[2026-06-02T02:00:40.472Z] Preferencia de día seleccionada: Mañana; se consultó disponibilidad real de Calendar para ofrecer horas libres",1,,,1,2026-06-02 01:58:19.633703+00,2026-06-02 02:00:40.489624+00 diff --git a/out/ia360-handoff-before-update-20260602.json b/out/ia360-handoff-before-update-20260602.json new file mode 100644 index 0000000..ba5ff21 --- /dev/null +++ b/out/ia360-handoff-before-update-20260602.json @@ -0,0 +1 @@ +[{"updatedAt":"2026-06-02T00:08:06.863Z","createdAt":"2026-06-01T22:28:38.895Z","id":"IA360WhatsAppHandoffDraft20260601","name":"IA360 WhatsApp Handoff → EspoCRM Active","description":null,"active":true,"isArchived":false,"nodes":[{"parameters":{"httpMethod":"POST","path":"ia360-whatsapp-handoff","responseMode":"responseNode","options":{}},"id":"Webhook_IA360_Handoff","name":"Webhook IA360 Handoff","type":"n8n-nodes-base.webhook","typeVersion":2,"position":[-620,0]},{"parameters":{"mode":"runOnceForAllItems","jsCode":"\nconst payload = $input.first().json.body || $input.first().json;\nfunction cleanDigits(v) { return String(v || '').replace(/\\D/g, ''); }\nfunction required(v, name) { if (!v) throw new Error(`Missing ${name}`); return v; }\nconst espoBase = required($env.ESPOCRM_URL, 'ESPOCRM_URL').replace(/\\/$/, '') + '/api/v1';\nconst espoUser = required($env.ESPOCRM_ADMIN_USERNAME, 'ESPOCRM_ADMIN_USERNAME');\nconst espoPass = required($env.ESPOCRM_ADMIN_PASSWORD, 'ESPOCRM_ADMIN_PASSWORD');\nconst auth = 'Basic ' + Buffer.from(`${espoUser}:${espoPass}`).toString('base64');\nasync function espo(method, path, body) {\n const opts = { method, url: espoBase + path, headers: { Authorization: auth, 'Content-Type': 'application/json' }, json: true };\n if (body) opts.body = body;\n try {\n return await this.helpers.httpRequest(opts);\n } catch (e) {\n throw new Error(`Espo ${method} ${path} failed: ${e.message}`);\n }\n}\nasync function findByName(entity, name) {\n const qs = 'where%5B0%5D%5Btype%5D=equals' + '&where%5B0%5D%5Battribute%5D=name' + '&where%5B0%5D%5Bvalue%5D=' + encodeURIComponent(name) + '&maxSize=1';\n const data = await espo.call(this, 'GET', `/${entity}?${qs}`);\n return (data.list || [])[0] || null;\n}\nasync function upsertByName(entity, name, body) {\n const existing = await findByName.call(this, entity, name);\n if (existing && existing.id) {\n const updated = await espo.call(this, 'PUT', `/${entity}/${existing.id}`, body);\n return { action: 'updated', record: updated };\n }\n const created = await espo.call(this, 'POST', `/${entity}`, body);\n return { action: 'created', record: created };\n}\nif (payload.source !== 'forgechat-ia360-webhook') throw new Error('Invalid source');\nconst contact = payload.contact || {};\nconst trigger = payload.trigger || {};\nconst wa = cleanDigits(contact.contactNumber);\nif (!wa) throw new Error('Missing contact.contactNumber');\nconst displayName = contact.contactName || `WhatsApp ${wa}`;\nconst eventType = payload.eventType || 'ia360_handoff';\nconst targetStage = payload.targetStage || 'Agenda en proceso';\nconst priority = payload.priority === 'high' ? 'High' : 'Normal';\nconst marker = '[ForgeChat IA360 n8n Sync]';\nconst accountName = 'ForgeChat IA360 WhatsApp';\nconst contactName = `${displayName} WhatsApp IA360`;\nconst opportunityName = `IA360 WhatsApp - ${displayName}`;\nconst taskName = `IA360 WhatsApp handoff - ${displayName}`;\nconst summary = payload.summary || '';\nconst descriptionBase = `${marker}\\nEvento: ${eventType}\\nPrioridad: ${payload.priority || 'normal'}\\nStage ForgeChat: ${targetStage}\\nContacto WA: +${wa}\\nNombre: ${displayName}\\nÚltima respuesta: ${trigger.messageBody || ''}\\nResumen: ${summary}\\nOcurrió: ${payload.occurredAt || new Date().toISOString()}\\nAcciones recomendadas: ${(payload.recommendedActions || []).join(', ')}`;\nconst appUser = await espo.call(this, 'GET', '/App/user');\nconst currentUser = appUser.user || appUser;\nconst assignedUserId = currentUser.id;\nconst assignedUserName = currentUser.name || currentUser.userName || 'Admin';\nconst accountRes = await upsertByName.call(this, 'Account', accountName, { name: accountName, type: 'Customer', assignedUserId, assignedUserName, description: `${marker}\\nCuenta puente para handoffs IA360 generados desde ForgeChat WhatsApp. Último WA: +${wa}.` });\nconst parts = displayName.split(/\\s+/);\nconst firstName = parts.slice(0, -1).join(' ') || displayName;\nconst lastName = parts.length > 1 ? `${parts[parts.length - 1]} WhatsApp IA360` : 'WhatsApp IA360';\nconst contactRes = await upsertByName.call(this, 'Contact', contactName, { firstName, lastName, assignedUserId, assignedUserName, description: descriptionBase + '\\n\\nNota: WA se guarda en descripción para evitar rechazo por validación estricta de phoneNumber en EspoCRM.' });\nconst closeDate = new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10);\nconst opportunityRes = await upsertByName.call(this, 'Opportunity', opportunityName, { name: opportunityName, stage: targetStage.includes('Agenda') || eventType.includes('agenda') ? 'Qualification' : 'Prospecting', amount: 0, closeDate, assignedUserId, assignedUserName, description: descriptionBase });\nconst due = new Date(Date.now() + (priority === 'High' ? 2 : 24) * 3600000).toISOString().slice(0, 19).replace('T', ' ');\nconst taskRes = await upsertByName.call(this, 'Task', taskName, { name: taskName, status: 'Not Started', priority, dateEnd: due, assignedUserId, assignedUserName, description: descriptionBase + '\\n\\nSiguiente acción humana: revisar contexto en ForgeChat y proponer horario real; no contar como reunión confirmada hasta crear evento.' });\nreturn [{ json: { ok: true, source: 'n8n-ia360-handoff-code-httpRequest', eventType, wa, espocrm: { account: { action: accountRes.action, id: accountRes.record.id, name: accountRes.record.name }, contact: { action: contactRes.action, id: contactRes.record.id, name: contactRes.record.name }, opportunity: { action: opportunityRes.action, id: opportunityRes.record.id, name: opportunityRes.record.name, stage: opportunityRes.record.stage }, task: { action: taskRes.action, id: taskRes.record.id, name: taskRes.record.name, priority: taskRes.record.priority, status: taskRes.record.status } } } }];\n"},"id":"Code_EspoCRM_Sync","name":"Code EspoCRM Sync","type":"n8n-nodes-base.code","typeVersion":2,"position":[-300,0]},{"parameters":{"respondWith":"json","responseBody":"={{ $json }}","options":{}},"id":"Respond_OK","name":"Respond OK","type":"n8n-nodes-base.respondToWebhook","typeVersion":1.1,"position":[20,0]}],"connections":{"Webhook IA360 Handoff":{"main":[[{"node":"Code EspoCRM Sync","type":"main","index":0}]]},"Code EspoCRM Sync":{"main":[[{"node":"Respond OK","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"staticData":null,"meta":{"templateCredsSetupCompleted":true},"pinData":{},"versionId":"360c0bc2-0aac-49dd-9fd7-9196f48b6db5","activeVersionId":"360c0bc2-0aac-49dd-9fd7-9196f48b6db5","versionCounter":6,"triggerCount":1,"tags":[{"updatedAt":"2026-06-01T22:28:38.895Z","createdAt":"2026-06-01T22:28:38.895Z","id":"GevR8V8p4weYv4o9","name":"IA360"},{"updatedAt":"2026-06-01T22:28:38.895Z","createdAt":"2026-06-01T22:28:38.895Z","id":"JKdc06SnqEo5aUV4","name":"WhatsApp"},{"updatedAt":"2026-06-01T22:28:38.895Z","createdAt":"2026-06-01T22:28:38.895Z","id":"Mrv6HU61TvlSCBJN","name":"Draft"},{"updatedAt":"2026-06-01T23:28:07.256Z","createdAt":"2026-06-01T23:28:07.256Z","id":"FaXtEsPA6Gvn73ii","name":"EspoCRM"},{"updatedAt":"2026-06-01T23:28:07.256Z","createdAt":"2026-06-01T23:28:07.256Z","id":"i6lTsZ3zExF8Ofo5","name":"Active"}],"shared":[{"updatedAt":"2026-06-01T22:28:38.895Z","createdAt":"2026-06-01T22:28:38.895Z","role":"workflow:owner","workflowId":"IA360WhatsAppHandoffDraft20260601","projectId":"PkheZ3AbJclgQBDS","project":{"updatedAt":"2026-06-01T18:44:22.667Z","createdAt":"2026-05-31T21:43:25.896Z","id":"PkheZ3AbJclgQBDS","name":"Alek Zen ","type":"personal","icon":null,"description":null,"creatorId":"2572bebb-0460-4a1a-a2b7-5455dd549e21"}}],"versionMetadata":{"name":null,"description":null}}] \ No newline at end of file diff --git a/out/ia360-media-tests/ia360-100m-dolor-ejecutivo-ceo-agobiado.jpg b/out/ia360-media-tests/ia360-100m-dolor-ejecutivo-ceo-agobiado.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae57774e8cb01e7340dbe2a4f2bc76c16e0893b8 GIT binary patch literal 114226 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 @M!>XLkb38-b=b3Q$#d(&T!nlE0 zK7U~OmoLn}KNl`uC8Ej%Xb$&&*c{Q ziMqJhuZT`+9$+;UABEp6&vCw3uPoapK<8_0EZtISc|wB ze&FX&RR!)36LanieqvlVZOi*h?!>{WQahnpcMX~7ZQk^OMDCEi-(-mipJu;38ZzXpOTqO4VM%D z0B{A5 zT;J%Pelb4h+|7T(>)`mT(>VF#@QEU|95WxJvg7VQQ6mr;VB02o zjl(m`58sb+ywADB^(k~k$(}+`aX&`=RY34c$Ih#f?xWsH1R&Tu z&Nl&BWz@O%FNuG%iOl;a98m?sZz(x{idgIFWw}Tl;5mumXH9%=ar4YRG1txi0Ql$Q z@_L+3XEDjR)b`7U<}P`;-^Vv9XMgbV@p;7Czm8`=H<@zl;^3qZPk39IXLE_f=LEcJ zeb>#w^Bl_p+4C_CeZaDi+fxzcaZ!(Xq*0XOqm|0@DtNd!iWx>in)CDc!~5<{6$5 z_&SRfsIeyMbHv4lKII~uug<^ZxK2lKB@wER@h)G*_wnvtBM0(xIh@V6JVWDOsG`H% zyi4~N7jSWN4-C^TTpu#tWu3(L1#}XO)oe_S8ii`5_?H){Zi!YS`36(^nN-EXYC4?% z0K;Dw^NHi)zu~EK)qG$4Jp6Mj<3Aq$51uzK8<&}y_+#<6bB~^WFW`PHxx^Saak)j7 zT)4eUmx+1Y+`QqJE+dqg()~hlN4O?Yxx-JWju~Ur(I&MiFXv z4^a091!7whnM(ScVmwRFEZdoGqLBl0JrGp0&B}2w7RsZe0D=!wa^=iH#JdS=1)WNv zuN*}3o_P7!{4e73Ih_35^Ye3=vvQ+7PIvL;mpPk$F)m(bzxeSlnZ-?U@rd;=(ggU{r-fBcsg<Oivy72(Kbd5{ptk_ItwTZ@ zo&4|inngwnUq`^JnCOovlL^5v7;51a8QKE|u5V!N%p9%0?X+;u(G)j$x^JoI`QJ9m6*bL;Ox7zojqT#m7^N Kj;Ao;KmXYdoF)SR literal 0 HcmV?d00001 diff --git a/out/ia360-media-tests/ia360-100m-mecanismo-whatsapp-crm-bi.jpg b/out/ia360-media-tests/ia360-100m-mecanismo-whatsapp-crm-bi.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a8b8e627caf60b7f6e70030b7744a4746924e068 GIT binary patch literal 103770 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 { + if (!origin) return callback(null, true); + if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true); + callback(new Error('Not allowed by CORS')); + }, +})); + +app.use(cookieParser()); +// Capture the raw request body so the webhook route can verify Meta's +// X-Hub-Signature-256 HMAC over the exact bytes Meta signed. +app.use(express.json({ limit: '1mb', verify: (req, _res, buf) => { req.rawBody = buf; } })); + +// Serve uploaded files statically +app.use('/uploads', express.static(UPLOAD_DIR)); + +// Rate limiting +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 600, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.path === '/health', + keyGenerator: (req) => { + try { + const token = req.cookies?.forgecrm_token; + if (token) { + const decoded = require('jsonwebtoken').decode(token); + if (decoded?.username) return `user:${decoded.username}`; + } + } catch {} + return req.ip; + }, + handler: (req, res) => { + res.status(429).json({ error: 'Too many requests, please try again later' }); + }, +}); +app.use(apiLimiter); + +// Health check +app.get('/health', (req, res) => res.json({ ok: true })); + +// Public routes (webhook from n8n — no auth) +app.use('/api', webhookRouter); +app.use('/api', ia360IntakeRouter); // B-28 intake (publico, secreto compartido) + +// Auth routes (public) +app.use('/api', authRouter); + +// Protected routes +app.use('/api', authMiddleware, messagesRouter); +app.use('/api', authMiddleware, categoriesRouter); +app.use('/api', authMiddleware, contactFieldsRouter); +app.use('/api', authMiddleware, usersRouter); +app.use('/api', authMiddleware, uploadsRouter); +app.use('/api', authMiddleware, templatesRouter); +app.use('/api', authMiddleware, broadcastsRouter); +app.use('/api', authMiddleware, chatbotsRouter); +app.use('/api', authMiddleware, mediaRouter); +app.use('/api', authMiddleware, mediaLibraryRouter); +app.use('/api', authMiddleware, whatsappAccountsRouter); +app.use('/api', authMiddleware, dashboardRouter); +app.use('/api', authMiddleware, pipelinesRouter); + +// Error handler +app.use((err, req, res, next) => { + // Full error (with stack) in dev for debugging; message-only in production. + if (process.env.NODE_ENV !== 'production') console.error('[Error]', err); + else console.error('[Error]', err.message); + res.status(500).json({ error: 'Internal server error' }); +}); + +// Start server +async function start() { + await ensureTables(); + mediaStorage.ensureBucket().catch(err => + console.error('[media-storage] table ensure failed (will retry on first upload):', err.message) + ); + startMediaWorker(); + startSendWorker(); + + // Stale-pause sweeper: mark paused automation executions that have outlived + // their expires_at as error. Resume already inline-checks expires_at, so + // this is purely hygiene against forever-paused rows accumulating. + setInterval(async () => { + try { + const { rowCount } = await pool.query( + `UPDATE coexistence.automation_executions + SET status='error', + error_message='Paused execution expired (no reply within timeout)', + completed_at=NOW() + WHERE status='paused' AND expires_at < NOW()` + ); + if (rowCount > 0) console.log(`[sweeper] expired ${rowCount} paused execution(s)`); + + // Reap orphaned 'running' executions: the engine runs synchronously and + // finishes in ms, so anything 'running' for >15m means the process died + // mid-walk (e.g. a restart) and the status was never updated to error. + const { rowCount: orphans } = await pool.query( + `UPDATE coexistence.automation_executions + SET status='error', + error_message='Execution interrupted (no completion within 15 minutes)', + completed_at=NOW() + WHERE status='running' AND started_at < NOW() - INTERVAL '15 minutes'` + ); + if (orphans > 0) console.log(`[sweeper] reaped ${orphans} orphaned running execution(s)`); + } catch (err) { + console.error('[sweeper] error:', err.message); + } + }, 30 * 60 * 1000).unref(); + + // Template status auto-sync: Meta does NOT push template approval/rejection + // status — we must poll. The tick fires every 10 min but only calls Meta while + // at least one template is still awaiting review (status='SUBMITTED'). Once all + // are resolved (approved/rejected/etc.) it idles with zero Meta calls, and + // auto-resumes when a new template is submitted. Override interval with + // TEMPLATE_SYNC_INTERVAL_MS. + const TEMPLATE_SYNC_MS = parseInt(process.env.TEMPLATE_SYNC_INTERVAL_MS || '', 10) || 10 * 60 * 1000; + const runTemplateSync = async () => { + try { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS pending FROM coexistence.message_templates WHERE status = 'SUBMITTED'` + ); + const pending = rows[0]?.pending || 0; + if (pending === 0) return; // all resolved → skip Meta entirely (idle) + const r = await syncAllAccountTemplates(); + if (r.totalUpdated > 0) { + console.log(`[template-sync] ${pending} pending → updated ${r.totalUpdated} template(s)`); + } + } catch (err) { + console.error('[template-sync] error:', err.message); + } + }; + setTimeout(runTemplateSync, 60 * 1000).unref(); // initial catch-up ~1 min after startup + setInterval(runTemplateSync, TEMPLATE_SYNC_MS).unref(); // every 10 min (gated by pending count) + + const server = app.listen(PORT, () => { + console.log(`[ForgeChat] Backend running on port ${PORT}`); + }); + + // Graceful shutdown so BullMQ marks in-flight jobs as stalled (not lost) + const shutdown = async (sig) => { + console.log(`[ForgeChat] ${sig} received, draining…`); + server.close(() => {}); + await shutdownMediaQueue(); + await shutdownSendQueue(); + process.exit(0); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +start().catch(err => { + console.error('[Fatal] Failed to start:', err.message); + process.exit(1); +}); diff --git a/backend/src/index.js.bak-cal-1780939394 b/backend/src/index.js.bak-cal-1780939394 new file mode 100644 index 0000000..40ebbfc --- /dev/null +++ b/backend/src/index.js.bak-cal-1780939394 @@ -0,0 +1,214 @@ +require('dotenv').config(); + +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const cookieParser = require('cookie-parser'); +const pool = require('./db'); +const { router: authRouter, authMiddleware, ensureTables } = require('./auth'); +const { router: messagesRouter } = require('./routes/messages'); +const { router: webhookRouter } = require('./routes/webhook'); +const { router: ia360IntakeRouter } = require('./routes/ia360-intake'); +const { router: categoriesRouter } = require('./routes/categories'); +const { router: contactFieldsRouter } = require('./routes/contactFields'); +const { router: usersRouter } = require('./routes/users'); +const { router: uploadsRouter, UPLOAD_DIR } = require('./routes/uploads'); +const { router: templatesRouter, syncAllAccountTemplates } = require('./routes/templates'); +const { router: broadcastsRouter } = require('./routes/broadcasts'); +const { router: chatbotsRouter } = require('./routes/chatbots'); +const { router: mediaRouter } = require('./routes/media'); +const { router: mediaLibraryRouter } = require('./routes/mediaLibrary'); +const mediaStorage = require('./util/pgStorage'); +const { router: whatsappAccountsRouter } = require('./routes/whatsappAccounts'); +const { router: dashboardRouter } = require('./routes/dashboard'); +const { router: pipelinesRouter } = require('./routes/pipelines'); +const { startWorker: startMediaWorker, shutdown: shutdownMediaQueue } = require('./queue/mediaQueue'); +const { startSendWorker, shutdownSendQueue } = require('./queue/sendQueue'); + +const app = express(); +const PORT = parseInt(process.env.PORT || '3001', 10); + +const ALLOWED_ORIGINS = [ + process.env.CORS_ORIGIN, + 'http://localhost:5173', +].filter(Boolean); + +const CORS_DOMAIN = (process.env.CORS_ORIGIN || '').replace(/^https?:\/\//, ''); + +// Security middleware +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "blob:"], + connectSrc: ["'self'", ...(CORS_DOMAIN ? [`wss://${CORS_DOMAIN}`] : [])], + mediaSrc: ["'self'", "blob:"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, + crossOriginEmbedderPolicy: false, +})); + +app.use(cors({ + credentials: true, + origin: (origin, callback) => { + if (!origin) return callback(null, true); + if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true); + callback(new Error('Not allowed by CORS')); + }, +})); + +app.use(cookieParser()); +// Capture the raw request body so the webhook route can verify Meta's +// X-Hub-Signature-256 HMAC over the exact bytes Meta signed. +app.use(express.json({ limit: '1mb', verify: (req, _res, buf) => { req.rawBody = buf; } })); + +// Serve uploaded files statically +app.use('/uploads', express.static(UPLOAD_DIR)); + +// Rate limiting +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 600, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.path === '/health', + keyGenerator: (req) => { + try { + const token = req.cookies?.forgecrm_token; + if (token) { + const decoded = require('jsonwebtoken').decode(token); + if (decoded?.username) return `user:${decoded.username}`; + } + } catch {} + return req.ip; + }, + handler: (req, res) => { + res.status(429).json({ error: 'Too many requests, please try again later' }); + }, +}); +app.use(apiLimiter); + +// Health check +app.get('/health', (req, res) => res.json({ ok: true })); + +// Public routes (webhook from n8n — no auth) +app.use('/api', webhookRouter); +app.use('/api', ia360IntakeRouter); // B-28 intake (publico, secreto compartido) + +// Auth routes (public) +app.use('/api', authRouter); + +// Protected routes +app.use('/api', authMiddleware, messagesRouter); +app.use('/api', authMiddleware, categoriesRouter); +app.use('/api', authMiddleware, contactFieldsRouter); +app.use('/api', authMiddleware, usersRouter); +app.use('/api', authMiddleware, uploadsRouter); +app.use('/api', authMiddleware, templatesRouter); +app.use('/api', authMiddleware, broadcastsRouter); +app.use('/api', authMiddleware, chatbotsRouter); +app.use('/api', authMiddleware, mediaRouter); +app.use('/api', authMiddleware, mediaLibraryRouter); +app.use('/api', authMiddleware, whatsappAccountsRouter); +app.use('/api', authMiddleware, dashboardRouter); +app.use('/api', authMiddleware, pipelinesRouter); + +// Error handler +app.use((err, req, res, next) => { + // Full error (with stack) in dev for debugging; message-only in production. + if (process.env.NODE_ENV !== 'production') console.error('[Error]', err); + else console.error('[Error]', err.message); + res.status(500).json({ error: 'Internal server error' }); +}); + +// Start server +async function start() { + await ensureTables(); + mediaStorage.ensureBucket().catch(err => + console.error('[media-storage] table ensure failed (will retry on first upload):', err.message) + ); + startMediaWorker(); + startSendWorker(); + + // Stale-pause sweeper: mark paused automation executions that have outlived + // their expires_at as error. Resume already inline-checks expires_at, so + // this is purely hygiene against forever-paused rows accumulating. + setInterval(async () => { + try { + const { rowCount } = await pool.query( + `UPDATE coexistence.automation_executions + SET status='error', + error_message='Paused execution expired (no reply within timeout)', + completed_at=NOW() + WHERE status='paused' AND expires_at < NOW()` + ); + if (rowCount > 0) console.log(`[sweeper] expired ${rowCount} paused execution(s)`); + + // Reap orphaned 'running' executions: the engine runs synchronously and + // finishes in ms, so anything 'running' for >15m means the process died + // mid-walk (e.g. a restart) and the status was never updated to error. + const { rowCount: orphans } = await pool.query( + `UPDATE coexistence.automation_executions + SET status='error', + error_message='Execution interrupted (no completion within 15 minutes)', + completed_at=NOW() + WHERE status='running' AND started_at < NOW() - INTERVAL '15 minutes'` + ); + if (orphans > 0) console.log(`[sweeper] reaped ${orphans} orphaned running execution(s)`); + } catch (err) { + console.error('[sweeper] error:', err.message); + } + }, 30 * 60 * 1000).unref(); + + // Template status auto-sync: Meta does NOT push template approval/rejection + // status — we must poll. The tick fires every 10 min but only calls Meta while + // at least one template is still awaiting review (status='SUBMITTED'). Once all + // are resolved (approved/rejected/etc.) it idles with zero Meta calls, and + // auto-resumes when a new template is submitted. Override interval with + // TEMPLATE_SYNC_INTERVAL_MS. + const TEMPLATE_SYNC_MS = parseInt(process.env.TEMPLATE_SYNC_INTERVAL_MS || '', 10) || 10 * 60 * 1000; + const runTemplateSync = async () => { + try { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS pending FROM coexistence.message_templates WHERE status = 'SUBMITTED'` + ); + const pending = rows[0]?.pending || 0; + if (pending === 0) return; // all resolved → skip Meta entirely (idle) + const r = await syncAllAccountTemplates(); + if (r.totalUpdated > 0) { + console.log(`[template-sync] ${pending} pending → updated ${r.totalUpdated} template(s)`); + } + } catch (err) { + console.error('[template-sync] error:', err.message); + } + }; + setTimeout(runTemplateSync, 60 * 1000).unref(); // initial catch-up ~1 min after startup + setInterval(runTemplateSync, TEMPLATE_SYNC_MS).unref(); // every 10 min (gated by pending count) + + const server = app.listen(PORT, () => { + console.log(`[ForgeChat] Backend running on port ${PORT}`); + }); + + // Graceful shutdown so BullMQ marks in-flight jobs as stalled (not lost) + const shutdown = async (sig) => { + console.log(`[ForgeChat] ${sig} received, draining…`); + server.close(() => {}); + await shutdownMediaQueue(); + await shutdownSendQueue(); + process.exit(0); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +start().catch(err => { + console.error('[Fatal] Failed to start:', err.message); + process.exit(1); +}); diff --git a/backend/src/queue/sendQueue.js.bak-pre-gbrain b/backend/src/queue/sendQueue.js.bak-pre-gbrain new file mode 100644 index 0000000..0dc18fa --- /dev/null +++ b/backend/src/queue/sendQueue.js.bak-pre-gbrain @@ -0,0 +1,198 @@ +// BullMQ outbound send queue. Rate-limited at 60 messages/sec by default +// (well under Meta Tier 1's 80/sec ceiling). All four send-origin paths +// (chat reply, broadcast, automation, template test) enqueue here. + +const { Queue, Worker, QueueEvents } = require('bullmq'); +const IORedis = require('ioredis'); +const pool = require('../db'); +const { getAccountWithToken } = require('../routes/whatsappAccounts'); +const { sendText, sendTemplate, sendMedia, sendInteractive, sendLocation, sendContacts, sendReaction } = require('../integrations/metaSend'); +const { markSent, markFailed } = require('../services/messageSender'); +const { markAccountHealth, classifyMetaError } = require('../services/accountHealth'); +const { validateTemplateSend } = require('../integrations/templateValidator'); + +const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379'; +const QUEUE_NAME = 'forgecrm-send'; +const CONCURRENCY = parseInt(process.env.SEND_QUEUE_CONCURRENCY || '5', 10); +const RATE_MAX = parseInt(process.env.SEND_RATE_MAX || '60', 10); +const RATE_DURATION_MS = parseInt(process.env.SEND_RATE_DURATION_MS || '1000', 10); +const ATTEMPTS = parseInt(process.env.SEND_QUEUE_ATTEMPTS || '4', 10); + +const connection = new IORedis(REDIS_URL, { + maxRetriesPerRequest: null, + enableReadyCheck: false, +}); +connection.on('error', err => console.error('[sendQueue] redis error:', err.message)); + +const sendQueue = new Queue(QUEUE_NAME, { connection }); + +let worker = null; +let queueEvents = null; + +/** + * Job data shape: + * { + * kind: 'text' | 'template' | 'media', + * accountId: number, // resolved WhatsApp account id + * to: string, // recipient phone (digits only) + * localMessageId: string, // matches the optimistic chat_history row + * payload: { // shape depends on kind + * // text: { body, previewUrl? } + * // template:{ name, languageCode, components, broadcastLogId? } + * // media: { type, mediaId | link, caption?, filename? } + * }, + * originRef?: { // optional cross-table linkage for status writes + * kind: 'broadcast_log' | 'automation_step', + * id: number, + * } + * } + */ +async function processJob(job) { + const { kind, accountId, to, localMessageId, payload, originRef } = job.data || {}; + const account = await getAccountWithToken(accountId); + if (!account) throw new Error(`Account id=${accountId} not found`); + if (!account.accessToken) throw new Error('Access token missing'); + if (!account.isActive) throw new Error(`Account "${account.displayName}" is inactive`); + + const args = { + accessToken: account.accessToken, + phoneNumberId: account.phoneNumberId, + to, + }; + + // Universal pre-Meta guard: block any template whose outgoing component shape + // (body/header-media/URL-button param counts) does not match the + // Meta-registered spec. This is the last choke point every template send + // funnels through, so it catches mismatches from ALL paths (owner pipe, + // reminders, automation, broadcasts, test-send) before they reach Meta and + // burn as #132000 / #132012. Thrown as skipRetry so the optimistic row is + // marked failed once, with the exact reason, and nothing hits Meta. + if (kind === 'template') { + const v = await validateTemplateSend(account, payload.name, payload.languageCode, payload.components); + if (!v.valid) { + const e = new Error(`template "${payload.name}" blocked pre-Meta: ${v.errors.join('; ')} [authority=${v.source}]`); + e.skipRetry = true; + e.templateBlocked = true; + throw e; + } + } + + let result; + try { + if (kind === 'text') { + result = await sendText({ ...args, body: payload.body, previewUrl: payload.previewUrl, contextMessageId: payload.contextMessageId }); + } else if (kind === 'template') { + result = await sendTemplate({ ...args, templateName: payload.name, languageCode: payload.languageCode, components: payload.components }); + } else if (kind === 'media') { + result = await sendMedia({ ...args, type: payload.type, mediaId: payload.mediaId, link: payload.link, caption: payload.caption, filename: payload.filename, contextMessageId: payload.contextMessageId }); + } else if (kind === 'interactive') { + result = await sendInteractive({ ...args, interactive: payload.interactive }); + } else if (kind === 'location') { + result = await sendLocation({ ...args, latitude: payload.latitude, longitude: payload.longitude, name: payload.name, address: payload.address }); + } else if (kind === 'contacts') { + result = await sendContacts({ ...args, contacts: payload.contacts }); + } else if (kind === 'reaction') { + result = await sendReaction({ ...args, messageId: payload.messageId, emoji: payload.emoji }); + } else { + throw new Error(`unknown send kind: ${kind}`); + } + await markAccountHealth(account.id, 'healthy'); + } catch (err) { + const cls = classifyMetaError(err); + await markAccountHealth(account.id, cls, err.message); + // Don't retry auth failures — they'll fail every time until token is fixed + if (cls === 'invalid_token') { + err.skipRetry = true; + } + throw err; + } + + const wamid = result?.messages?.[0]?.id; + if (!wamid) throw new Error('Meta returned no message id'); + + // Swap the optimistic row's local id for the real wamid + if (localMessageId) await markSent(localMessageId, wamid); + + // Update origin-side linkage (broadcast_log etc) if provided + if (originRef?.kind === 'broadcast_log' && originRef.id) { + await pool.query( + `UPDATE coexistence.broadcast_logs + SET status = 'sent', wa_message_id = $1, sent_at = NOW() + WHERE id = $2`, + [wamid, originRef.id] + ).catch(err => console.error('[sendQueue] broadcast_log update failed:', err.message)); + } + if (originRef?.kind === 'automation_step' && originRef.id) { + await pool.query( + `UPDATE coexistence.automation_execution_steps + SET wa_message_id = $1, wa_message_status = 'sent' + WHERE id = $2`, + [wamid, originRef.id] + ).catch(() => {}); + } + + return { wamid }; +} + +function startSendWorker() { + if (worker) return worker; + worker = new Worker(QUEUE_NAME, processJob, { + connection, + concurrency: CONCURRENCY, + limiter: { max: RATE_MAX, duration: RATE_DURATION_MS }, + }); + + worker.on('completed', (job) => { + console.log(`[sendQueue] ${job.data?.kind} to ${job.data?.to} → ${job.returnvalue?.wamid}`); + }); + worker.on('failed', async (job, err) => { + const localId = job?.data?.localMessageId; + const skipRetry = err?.skipRetry || /invalid.*token|access token has expired|Error validating access token/i.test(err?.message || ''); + const finalAttempt = (job?.attemptsMade || 0) >= ATTEMPTS || skipRetry; + console.error(`[sendQueue] ${job?.data?.kind} to ${job?.data?.to} failed attempt=${job?.attemptsMade}/${ATTEMPTS}${skipRetry ? ' (no-retry: auth)' : ''}: ${err.message}`); + if (finalAttempt && localId) { + await markFailed(localId, err.message).catch(() => {}); + if (job?.data?.originRef?.kind === 'broadcast_log') { + await pool.query( + `UPDATE coexistence.broadcast_logs SET status='failed', error_message=$1 WHERE id=$2`, + [err.message.slice(0, 500), job.data.originRef.id] + ).catch(() => {}); + } + } + }); + + queueEvents = new QueueEvents(QUEUE_NAME, { connection }); + queueEvents.on('error', err => console.error('[sendQueue] events error:', err.message)); + + console.log(`[sendQueue] worker started, concurrency=${CONCURRENCY}, rate=${RATE_MAX}/${RATE_DURATION_MS}ms, attempts=${ATTEMPTS}`); + return worker; +} + +async function enqueueSend(jobData, opts = {}) { + const idKey = jobData.localMessageId || `${jobData.accountId}-${jobData.to}-${Date.now()}`; + const addOpts = { + jobId: `send-${idKey}`, + attempts: ATTEMPTS, + backoff: { type: 'exponential', delay: 1500 }, + removeOnComplete: { count: 500, age: 3600 }, + removeOnFail: { count: 1000, age: 86400 }, + }; + // Optional delayed delivery (used by automation Delay nodes so a later message + // lands after an earlier one). BullMQ holds the job for `delayMs` before a + // worker picks it up — non-blocking, no scheduler needed. + if (opts.delayMs && opts.delayMs > 0) addOpts.delay = Math.round(opts.delayMs); + await sendQueue.add('send', jobData, addOpts); +} + +async function shutdownSendQueue() { + try { + if (worker) await worker.close(); + if (queueEvents) await queueEvents.close(); + await sendQueue.close(); + await connection.quit(); + } catch (err) { + console.error('[sendQueue] shutdown error:', err.message); + } +} + +module.exports = { sendQueue, startSendWorker, enqueueSend, shutdownSendQueue }; diff --git a/backend/src/queue/sendQueue.js.bak-pre-validator-20260609T194227Z b/backend/src/queue/sendQueue.js.bak-pre-validator-20260609T194227Z new file mode 100644 index 0000000..e8a8fec --- /dev/null +++ b/backend/src/queue/sendQueue.js.bak-pre-validator-20260609T194227Z @@ -0,0 +1,180 @@ +// BullMQ outbound send queue. Rate-limited at 60 messages/sec by default +// (well under Meta Tier 1's 80/sec ceiling). All four send-origin paths +// (chat reply, broadcast, automation, template test) enqueue here. + +const { Queue, Worker, QueueEvents } = require('bullmq'); +const IORedis = require('ioredis'); +const pool = require('../db'); +const { getAccountWithToken } = require('../routes/whatsappAccounts'); +const { sendText, sendTemplate, sendMedia, sendInteractive, sendLocation, sendContacts, sendReaction } = require('../integrations/metaSend'); +const { markSent, markFailed } = require('../services/messageSender'); +const { markAccountHealth, classifyMetaError } = require('../services/accountHealth'); + +const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379'; +const QUEUE_NAME = 'forgecrm-send'; +const CONCURRENCY = parseInt(process.env.SEND_QUEUE_CONCURRENCY || '5', 10); +const RATE_MAX = parseInt(process.env.SEND_RATE_MAX || '60', 10); +const RATE_DURATION_MS = parseInt(process.env.SEND_RATE_DURATION_MS || '1000', 10); +const ATTEMPTS = parseInt(process.env.SEND_QUEUE_ATTEMPTS || '4', 10); + +const connection = new IORedis(REDIS_URL, { + maxRetriesPerRequest: null, + enableReadyCheck: false, +}); +connection.on('error', err => console.error('[sendQueue] redis error:', err.message)); + +const sendQueue = new Queue(QUEUE_NAME, { connection }); + +let worker = null; +let queueEvents = null; + +/** + * Job data shape: + * { + * kind: 'text' | 'template' | 'media', + * accountId: number, // resolved WhatsApp account id + * to: string, // recipient phone (digits only) + * localMessageId: string, // matches the optimistic chat_history row + * payload: { // shape depends on kind + * // text: { body, previewUrl? } + * // template:{ name, languageCode, components, broadcastLogId? } + * // media: { type, mediaId | link, caption?, filename? } + * }, + * originRef?: { // optional cross-table linkage for status writes + * kind: 'broadcast_log' | 'automation_step', + * id: number, + * } + * } + */ +async function processJob(job) { + const { kind, accountId, to, localMessageId, payload, originRef } = job.data || {}; + const account = await getAccountWithToken(accountId); + if (!account) throw new Error(`Account id=${accountId} not found`); + if (!account.accessToken) throw new Error('Access token missing'); + if (!account.isActive) throw new Error(`Account "${account.displayName}" is inactive`); + + const args = { + accessToken: account.accessToken, + phoneNumberId: account.phoneNumberId, + to, + }; + + let result; + try { + if (kind === 'text') { + result = await sendText({ ...args, body: payload.body, previewUrl: payload.previewUrl, contextMessageId: payload.contextMessageId }); + } else if (kind === 'template') { + result = await sendTemplate({ ...args, templateName: payload.name, languageCode: payload.languageCode, components: payload.components }); + } else if (kind === 'media') { + result = await sendMedia({ ...args, type: payload.type, mediaId: payload.mediaId, link: payload.link, caption: payload.caption, filename: payload.filename, contextMessageId: payload.contextMessageId }); + } else if (kind === 'interactive') { + result = await sendInteractive({ ...args, interactive: payload.interactive }); + } else if (kind === 'location') { + result = await sendLocation({ ...args, latitude: payload.latitude, longitude: payload.longitude, name: payload.name, address: payload.address }); + } else if (kind === 'contacts') { + result = await sendContacts({ ...args, contacts: payload.contacts }); + } else if (kind === 'reaction') { + result = await sendReaction({ ...args, messageId: payload.messageId, emoji: payload.emoji }); + } else { + throw new Error(`unknown send kind: ${kind}`); + } + await markAccountHealth(account.id, 'healthy'); + } catch (err) { + const cls = classifyMetaError(err); + await markAccountHealth(account.id, cls, err.message); + // Don't retry auth failures — they'll fail every time until token is fixed + if (cls === 'invalid_token') { + err.skipRetry = true; + } + throw err; + } + + const wamid = result?.messages?.[0]?.id; + if (!wamid) throw new Error('Meta returned no message id'); + + // Swap the optimistic row's local id for the real wamid + if (localMessageId) await markSent(localMessageId, wamid); + + // Update origin-side linkage (broadcast_log etc) if provided + if (originRef?.kind === 'broadcast_log' && originRef.id) { + await pool.query( + `UPDATE coexistence.broadcast_logs + SET status = 'sent', wa_message_id = $1, sent_at = NOW() + WHERE id = $2`, + [wamid, originRef.id] + ).catch(err => console.error('[sendQueue] broadcast_log update failed:', err.message)); + } + if (originRef?.kind === 'automation_step' && originRef.id) { + await pool.query( + `UPDATE coexistence.automation_execution_steps + SET wa_message_id = $1, wa_message_status = 'sent' + WHERE id = $2`, + [wamid, originRef.id] + ).catch(() => {}); + } + + return { wamid }; +} + +function startSendWorker() { + if (worker) return worker; + worker = new Worker(QUEUE_NAME, processJob, { + connection, + concurrency: CONCURRENCY, + limiter: { max: RATE_MAX, duration: RATE_DURATION_MS }, + }); + + worker.on('completed', (job) => { + console.log(`[sendQueue] ${job.data?.kind} to ${job.data?.to} → ${job.returnvalue?.wamid}`); + }); + worker.on('failed', async (job, err) => { + const localId = job?.data?.localMessageId; + const skipRetry = err?.skipRetry || /invalid.*token|access token has expired|Error validating access token/i.test(err?.message || ''); + const finalAttempt = (job?.attemptsMade || 0) >= ATTEMPTS || skipRetry; + console.error(`[sendQueue] ${job?.data?.kind} to ${job?.data?.to} failed attempt=${job?.attemptsMade}/${ATTEMPTS}${skipRetry ? ' (no-retry: auth)' : ''}: ${err.message}`); + if (finalAttempt && localId) { + await markFailed(localId, err.message).catch(() => {}); + if (job?.data?.originRef?.kind === 'broadcast_log') { + await pool.query( + `UPDATE coexistence.broadcast_logs SET status='failed', error_message=$1 WHERE id=$2`, + [err.message.slice(0, 500), job.data.originRef.id] + ).catch(() => {}); + } + } + }); + + queueEvents = new QueueEvents(QUEUE_NAME, { connection }); + queueEvents.on('error', err => console.error('[sendQueue] events error:', err.message)); + + console.log(`[sendQueue] worker started, concurrency=${CONCURRENCY}, rate=${RATE_MAX}/${RATE_DURATION_MS}ms, attempts=${ATTEMPTS}`); + return worker; +} + +async function enqueueSend(jobData, opts = {}) { + const idKey = jobData.localMessageId || `${jobData.accountId}-${jobData.to}-${Date.now()}`; + const addOpts = { + jobId: `send-${idKey}`, + attempts: ATTEMPTS, + backoff: { type: 'exponential', delay: 1500 }, + removeOnComplete: { count: 500, age: 3600 }, + removeOnFail: { count: 1000, age: 86400 }, + }; + // Optional delayed delivery (used by automation Delay nodes so a later message + // lands after an earlier one). BullMQ holds the job for `delayMs` before a + // worker picks it up — non-blocking, no scheduler needed. + if (opts.delayMs && opts.delayMs > 0) addOpts.delay = Math.round(opts.delayMs); + await sendQueue.add('send', jobData, addOpts); +} + +async function shutdownSendQueue() { + try { + if (worker) await worker.close(); + if (queueEvents) await queueEvents.close(); + await sendQueue.close(); + await connection.quit(); + } catch (err) { + console.error('[sendQueue] shutdown error:', err.message); + } +} + +module.exports = { sendQueue, startSendWorker, enqueueSend, shutdownSendQueue }; diff --git a/backend/src/routes/ia360-intake.js.bak-profilename-20260604T214651Z b/backend/src/routes/ia360-intake.js.bak-profilename-20260604T214651Z new file mode 100644 index 0000000..be9e1a5 --- /dev/null +++ b/backend/src/routes/ia360-intake.js.bak-profilename-20260604T214651Z @@ -0,0 +1,119 @@ +'use strict'; + +// ============================================================================ +// B-28 — Endpoint de alta de contacto (S0 fuente web) +// ---------------------------------------------------------------------------- +// INVARIANTE DURO: captura != envio. Este endpoint SOLO escribe a +// coexistence.contacts con staged=true. NUNCA encola ni envia un mensaje. +// Prohibido importar/llamar: enqueueSend, insertPendingRow, resolveAccount, +// enqueueIa360Interactive/Flow/Text, sendOwnerInteractive, sendIa360DirectText, +// ni fetch hacia graph.facebook.com. Solo pool.query. +// +// Lo llama el workflow n8n "IA360 Alta Contacto Web (B-28)" por la URL publica +// https://wa.geekstudio.dev/api/ia360-intake (n8n y forgecrm estan en redes +// docker distintas). Auth = header secreto compartido X-IA360-Intake-Secret. +// Montado en index.js en la zona PUBLICA (sin authMiddleware), debajo de +// app.use('/api', webhookRouter). +// ============================================================================ + +const { Router } = require('express'); +const crypto = require('crypto'); +const pool = require('../db'); + +const router = Router(); + +const INTAKE_SECRET = process.env.IA360_INTAKE_SECRET || ''; +// Linea de negocio WABA IA360 = a quien le escribiria el contacto (wa_number). +const IA360_BUSINESS_WA_NUMBER = process.env.IA360_BUSINESS_WA_NUMBER || '5213321594582'; + +function timingSafeEqualStr(a, b) { + const ba = Buffer.from(String(a == null ? '' : a)); + const bb = Buffer.from(String(b == null ? '' : b)); + if (ba.length !== bb.length) return false; + return crypto.timingSafeEqual(ba, bb); +} + +function onlyDigits(s) { + return String(s == null ? '' : s).replace(/[^0-9]/g, ''); +} + +// Normaliza a E.164 (solo digitos, sin '+'), default Mexico movil (521 + 10). +function normalizeE164Mx(raw) { + let d = onlyDigits(raw); + if (!d) return null; + if (d.startsWith('00')) d = d.slice(2); // prefijo internacional 00 + if (d.length === 10) return '521' + d; // 10 digitos MX -> 521 + 10 + if (d.length === 12 && d.startsWith('52')) return '521' + d.slice(2); // 52 + 10 -> 521 + 10 + if (d.length === 13 && d.startsWith('521')) return d; // ya normalizado + if (d.length === 11 && d.startsWith('1')) return d; // US/CA: 1 + 10 + return d; // internacional u otro: deja los digitos crudos +} + +router.post('/ia360-intake', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe) + const provided = req.get('X-IA360-Intake-Secret') || ''; + if (!INTAKE_SECRET || !timingSafeEqualStr(provided, INTAKE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + + try { + const b = req.body || {}; + + // 2) Llave del contacto: contact_number normalizado a E.164 + const contactNumber = normalizeE164Mx(b.contact_number || b.telefono); + if (!contactNumber) { + return res.status(422).json({ + ok: false, + error: 'contact_number_required', + detail: 'sin telefono normalizable a E.164 no hay llave para coexistence.contacts' + }); + } + const waNumber = onlyDigits(b.wa_number) || IA360_BUSINESS_WA_NUMBER; + const name = (b.name || b.nombre || null); + + // 3) tags entrantes (array de strings) -> merge DISTINCT + const tags = Array.isArray(b.tags) + ? b.tags.filter((t) => typeof t === 'string' && t.trim()) + : []; + + // 4) custom_fields entrantes (objeto) -> staged SIEMPRE true (invariante B-28) + const cf = (b.custom_fields && typeof b.custom_fields === 'object' && !Array.isArray(b.custom_fields)) + ? { ...b.custom_fields } + : {}; + cf.staged = true; // capturado, FUERA del auto-ruteo R0 + if (!cf.stage) cf.stage = 'Capturado / Por rutear'; + if (!cf.captured_at) cf.captured_at = new Date().toISOString(); + cf.intake_source = cf.intake_source || 'b28-web-form'; + + // 5) Upsert idempotente (patron mergeContactIa360State + name). + // (xmax = 0) distingue INSERT nuevo (true) de UPDATE por conflicto (false). + const result = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, tags, custom_fields, created_at, updated_at, (xmax = 0) AS inserted`, + [waNumber, contactNumber, name, JSON.stringify(tags), JSON.stringify(cf)] + ); + + const row = result.rows[0]; + const inserted = row.inserted === true; + delete row.inserted; + + // INVARIANTE B-28: cero outbound. No se encola ni envia nada. + return res.status(200).json({ ok: true, staged: true, deduped: !inserted, contact: row }); + } catch (err) { + console.error('[ia360-intake] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'intake_failed', detail: err && err.message }); + } +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js b/backend/src/routes/webhook.js index 7351871..9906767 100644 --- a/backend/src/routes/webhook.js +++ b/backend/src/routes/webhook.js @@ -4440,8 +4440,13 @@ async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceI // syncIa360Deal), así que un deal en "Introducción enviada"/posterior solo recibe // una nota, no regresa de stage. if (IA360_PARTNER_RELATIONSHIPS.has(String(flow?.relationshipContext || ''))) { + // BUGFIX G10: el deal es del CONTACTO partner, no del owner. Este handler corre + // sobre el inbound del owner (record.contact_number = owner), así que hay que + // re-scopear el record al targetContact (mismo patrón que targetRecord en + // handleIa360OwnerApproveSend/Manual). Sin esto, syncIa360Deal crea el deal + // bajo el número del owner en el pipeline Partners. await syncIa360Deal({ - record, + record: { ...record, contact_number: targetContact, contact_name: name }, targetStageName: 'Fit identificado', titleSuffix: 'Fit (secuencia elegida)', notes: `Partner identificado como fit: owner eligió secuencia ${sequence.id} (pre-envío).`, diff --git a/backend/src/routes/webhook.js.bak- b/backend/src/routes/webhook.js.bak- new file mode 100644 index 0000000..5e1ba01 --- /dev/null +++ b/backend/src/routes/webhook.js.bak- @@ -0,0 +1,3859 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nElige la intención real. Solo las rutas con copy aprobado envían mensaje; las demás quedan para gestión humana.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, envío con tu tap' }, + action: { + button: 'Elegir pipeline', + sections: [{ + title: 'IA360', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:p1`, title: 'Diagnóstico IA360', description: 'Vivo: abre con dolor operativo' }, + { id: `owner_pipe:${shared.contactNumber}:p3`, title: 'Agenda diagnóstico', description: 'Vivo: manda horarios' }, + { id: `owner_pipe:${shared.contactNumber}:beta`, title: 'Beta / conocido', description: 'Sin envío automático' }, + { id: `owner_pipe:${shared.contactNumber}:referido`, title: 'Referido / BNI', description: 'Sin envío automático' }, + { id: `owner_pipe:${shared.contactNumber}:aliado`, title: 'Aliado / socio', description: 'Sin envío automático' }, + { id: `owner_pipe:${shared.contactNumber}:deleitar`, title: 'Cliente activo', description: 'Sin envío automático' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + const liveRoutes = { + p1: { + label: 'Diagnóstico IA360', + stage: 'Diagnóstico enviado', + tag: 'owner-pipe-prospecto-frio', + templateName: 'ia360_100m_img_01_dolor', + templateId: 16, + eventType: 'owner_pipe_prospecto_frio', + summary: 'Alek eligió Diagnóstico IA360 desde vCard/formulario; se seleccionó apertura IA360 100M enfocada en dolor operativo.', + }, + p3: { + label: 'Agenda diagnóstico', + stage: 'Agenda en proceso', + tag: 'owner-pipe-calificado-agenda', + eventType: 'owner_pipe_calificado_agenda', + summary: 'Alek eligió Agenda diagnóstico desde vCard/formulario; se enviaron horarios disponibles.', + }, + }; + + if (choice === 'guardar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['owner-pipe-guardar'], + customFields: { staged: true, stage: 'Capturado / Por rutear', pipeline_sugerido: 'guardar', owner_action: 'solo_guardar', owner_action_at: new Date().toISOString() }, + }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_guardar_ack', body: `Listo: ${name} queda capturado sin envío. No mandé nada al contacto.` }); + return; + } + + if (choice === 'excluir') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['no-contactar', 'owner-pipe-excluir'], + customFields: { staged: false, stage: 'Perdido / no fit', pipeline_sugerido: 'excluir', owner_action: 'excluir', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Perdido / no fit', titleSuffix: 'No contactar', notes: 'Alek eligió excluir/no contactar desde owner_pipe.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_excluir_ack', body: `Listo: ${name} queda marcado como no contactar. No envié nada.` }); + return; + } + + const route = liveRoutes[choice]; + if (!route) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['owner-pipe-pendiente', `owner-pipe-${choice || 'sin-ruta'}`], + customFields: { staged: false, stage: 'Requiere Alek', pipeline_sugerido: choice || null, owner_action: 'pipeline_pendiente', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Requiere Alek', titleSuffix: `Pipeline ${choice || 'pendiente'}`, notes: `Alek eligió pipeline ${choice || 'pendiente'}, pero aún no está cableado para envío automático.` }); + await emitIa360N8nHandoff({ record: targetRecord, eventType: 'owner_pipe_pending', targetStage: 'Requiere Alek', priority: 'high', summary: `Pipeline ${choice || 'pendiente'} elegido para ${name}; requiere diseño/copy antes de enviar.` }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_pending_ack', body: `Ese pipeline todavía no está cableado para envío automático. Dejé a ${name} en Requiere Alek y no envié nada.` }); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['pipeline-wa-aprobado', route.tag], + customFields: { + staged: false, + stage: route.stage, + pipeline_sugerido: choice, + owner_action: `owner_pipe_${choice}`, + owner_action_at: new Date().toISOString(), + ultimo_cta_enviado: route.templateName || 'owner_pipe_slots', + }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: route.stage, titleSuffix: route.label, notes: route.summary }); + + let sendResult = { ok: false, status: 'not_sent' }; + if (choice === 'p3') { + const ok = await sendOwnerPipelineSlots({ record: targetRecord }); + sendResult = { ok, status: ok ? 'queued' : 'failed' }; + } else { + sendResult = await enqueueIa360Template({ + record: targetRecord, + label: `owner_pipe_${choice}`, + templateName: route.templateName, + templateId: route.templateId, + }); + } + const sent = !!sendResult.ok; + const queued = !sent && ['sending', 'queued', 'unknown'].includes(String(sendResult.status || '').toLowerCase()); + + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-owner-pipe', + agent: { + intent: 'owner_pipeline_selected', + action: sent ? 'pipeline_sent' : (queued ? 'pipeline_queued' : 'pipeline_send_failed'), + extracted: { pipeline: choice, stage: route.stage, route: route.label, send_status: sendResult.status || null }, + }, + }).catch(e => console.error('[ia360-owner-pipe] crm reflect:', e.message)); + emitIa360N8nHandoff({ + record: targetRecord, + eventType: route.eventType, + targetStage: route.stage, + priority: choice === 'p3' ? 'high' : 'normal', + summary: route.summary, + }).catch(e => console.error('[ia360-owner-pipe] handoff:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: sent ? 'owner_pipe_sent_ack' : (queued ? 'owner_pipe_queued_ack' : 'owner_pipe_failed_ack'), + body: sent + ? `Listo: ${name} entró a ${route.label} y ya le envié el siguiente paso.` + : queued + ? `Listo: ${name} entró a ${route.label}. Dejé el siguiente paso en cola de envío; si no aparece como enviado en ForgeChat, reviso la cola.` + : `Intenté enviar ${route.label} a ${name}, pero falló el envío${sendResult.error ? `: ${sendResult.error}` : ''}. Lo dejé en ${route.stage} para revisión.`, + }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-20260605213800 b/backend/src/routes/webhook.js.bak-20260605213800 new file mode 100644 index 0000000..298f246 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-20260605213800 @@ -0,0 +1,3851 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: String(samples[k] || record.contact_name || record.profile_name || 'Alek' || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nElige qué pipeline/flow quieres disparar. Tu tap es la aprobación de envío.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, envío con tu tap' }, + action: { + button: 'Elegir pipeline', + sections: [{ + title: 'IA360', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:p1`, title: 'Prospecto frío', description: 'Envía apertura IA360 100M' }, + { id: `owner_pipe:${shared.contactNumber}:p3`, title: 'Calificado / agenda', description: 'Envía horarios disponibles' }, + { id: `owner_pipe:${shared.contactNumber}:deleitar`, title: 'Cliente activo', description: 'Deleitar: por construir' }, + { id: `owner_pipe:${shared.contactNumber}:nutricion`, title: 'Nutrición', description: 'Envía reactivación suave' }, + { id: `owner_pipe:${shared.contactNumber}:aliado`, title: 'Aliado / socio', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:bni`, title: 'BNI / referido', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:amigo`, title: 'Amigo suave', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Marca exclusión, sin envío' }, + ], + }], + }, + }, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + const liveRoutes = { + p1: { + label: 'Prospecto frío', + stage: 'Diagnóstico enviado', + tag: 'owner-pipe-prospecto-frio', + templateName: 'ia360_100m_img_04_alek', + templateId: 19, + eventType: 'owner_pipe_prospecto_frio', + summary: 'Alek eligió Prospecto frío desde vCard/formulario; se seleccionó apertura IA360 100M.', + }, + p3: { + label: 'Calificado / agenda', + stage: 'Agenda en proceso', + tag: 'owner-pipe-calificado-agenda', + eventType: 'owner_pipe_calificado_agenda', + summary: 'Alek eligió Calificado / agenda desde vCard/formulario; se enviaron horarios disponibles.', + }, + nutricion: { + label: 'Nutrición', + stage: 'Nutrición', + tag: 'owner-pipe-nutricion', + templateName: 'ia360_100m_06_reactivacion', + templateId: 15, + eventType: 'owner_pipe_nutricion', + summary: 'Alek eligió Nutrición desde vCard/formulario; se seleccionó reactivación suave.', + }, + }; + + if (choice === 'excluir') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['no-contactar', 'owner-pipe-excluir'], + customFields: { staged: false, stage: 'Perdido / no fit', pipeline_sugerido: 'excluir', owner_action: 'excluir', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Perdido / no fit', titleSuffix: 'No contactar', notes: 'Alek eligió excluir/no contactar desde owner_pipe.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_excluir_ack', body: `Listo: ${name} queda marcado como no contactar. No envié nada.` }); + return; + } + + const route = liveRoutes[choice]; + if (!route) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['owner-pipe-pendiente', `owner-pipe-${choice || 'sin-ruta'}`], + customFields: { staged: false, stage: 'Requiere Alek', pipeline_sugerido: choice || null, owner_action: 'pipeline_pendiente', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Requiere Alek', titleSuffix: `Pipeline ${choice || 'pendiente'}`, notes: `Alek eligió pipeline ${choice || 'pendiente'}, pero aún no está cableado para envío automático.` }); + await emitIa360N8nHandoff({ record: targetRecord, eventType: 'owner_pipe_pending', targetStage: 'Requiere Alek', priority: 'high', summary: `Pipeline ${choice || 'pendiente'} elegido para ${name}; requiere diseño/copy antes de enviar.` }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_pending_ack', body: `Ese pipeline todavía no está cableado para envío automático. Dejé a ${name} en Requiere Alek y no envié nada.` }); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['pipeline-wa-aprobado', route.tag], + customFields: { + staged: false, + stage: route.stage, + pipeline_sugerido: choice, + owner_action: `owner_pipe_${choice}`, + owner_action_at: new Date().toISOString(), + ultimo_cta_enviado: route.templateName || 'owner_pipe_slots', + }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: route.stage, titleSuffix: route.label, notes: route.summary }); + + let sendResult = { ok: false, status: 'not_sent' }; + if (choice === 'p3') { + const ok = await sendOwnerPipelineSlots({ record: targetRecord }); + sendResult = { ok, status: ok ? 'queued' : 'failed' }; + } else { + sendResult = await enqueueIa360Template({ + record: targetRecord, + label: `owner_pipe_${choice}`, + templateName: route.templateName, + templateId: route.templateId, + }); + } + const sent = !!sendResult.ok; + const queued = !sent && ['sending', 'queued', 'unknown'].includes(String(sendResult.status || '').toLowerCase()); + + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-owner-pipe', + agent: { + intent: 'owner_pipeline_selected', + action: sent ? 'pipeline_sent' : (queued ? 'pipeline_queued' : 'pipeline_send_failed'), + extracted: { pipeline: choice, stage: route.stage, route: route.label, send_status: sendResult.status || null }, + }, + }).catch(e => console.error('[ia360-owner-pipe] crm reflect:', e.message)); + emitIa360N8nHandoff({ + record: targetRecord, + eventType: route.eventType, + targetStage: route.stage, + priority: choice === 'p3' ? 'high' : 'normal', + summary: route.summary, + }).catch(e => console.error('[ia360-owner-pipe] handoff:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: sent ? 'owner_pipe_sent_ack' : (queued ? 'owner_pipe_queued_ack' : 'owner_pipe_failed_ack'), + body: sent + ? `Listo: ${name} entró a ${route.label} y ya le envié el siguiente paso.` + : queued + ? `Listo: ${name} entró a ${route.label}. Dejé el siguiente paso en cola de envío; si no aparece como enviado en ForgeChat, reviso la cola.` + : `Intenté enviar ${route.label} a ${name}, pero falló el envío${sendResult.error ? `: ${sendResult.error}` : ''}. Lo dejé en ${route.stage} para revisión.`, + }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-20260605214238 b/backend/src/routes/webhook.js.bak-20260605214238 new file mode 100644 index 0000000..298f246 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-20260605214238 @@ -0,0 +1,3851 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: String(samples[k] || record.contact_name || record.profile_name || 'Alek' || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nElige qué pipeline/flow quieres disparar. Tu tap es la aprobación de envío.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, envío con tu tap' }, + action: { + button: 'Elegir pipeline', + sections: [{ + title: 'IA360', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:p1`, title: 'Prospecto frío', description: 'Envía apertura IA360 100M' }, + { id: `owner_pipe:${shared.contactNumber}:p3`, title: 'Calificado / agenda', description: 'Envía horarios disponibles' }, + { id: `owner_pipe:${shared.contactNumber}:deleitar`, title: 'Cliente activo', description: 'Deleitar: por construir' }, + { id: `owner_pipe:${shared.contactNumber}:nutricion`, title: 'Nutrición', description: 'Envía reactivación suave' }, + { id: `owner_pipe:${shared.contactNumber}:aliado`, title: 'Aliado / socio', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:bni`, title: 'BNI / referido', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:amigo`, title: 'Amigo suave', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Marca exclusión, sin envío' }, + ], + }], + }, + }, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + const liveRoutes = { + p1: { + label: 'Prospecto frío', + stage: 'Diagnóstico enviado', + tag: 'owner-pipe-prospecto-frio', + templateName: 'ia360_100m_img_04_alek', + templateId: 19, + eventType: 'owner_pipe_prospecto_frio', + summary: 'Alek eligió Prospecto frío desde vCard/formulario; se seleccionó apertura IA360 100M.', + }, + p3: { + label: 'Calificado / agenda', + stage: 'Agenda en proceso', + tag: 'owner-pipe-calificado-agenda', + eventType: 'owner_pipe_calificado_agenda', + summary: 'Alek eligió Calificado / agenda desde vCard/formulario; se enviaron horarios disponibles.', + }, + nutricion: { + label: 'Nutrición', + stage: 'Nutrición', + tag: 'owner-pipe-nutricion', + templateName: 'ia360_100m_06_reactivacion', + templateId: 15, + eventType: 'owner_pipe_nutricion', + summary: 'Alek eligió Nutrición desde vCard/formulario; se seleccionó reactivación suave.', + }, + }; + + if (choice === 'excluir') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['no-contactar', 'owner-pipe-excluir'], + customFields: { staged: false, stage: 'Perdido / no fit', pipeline_sugerido: 'excluir', owner_action: 'excluir', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Perdido / no fit', titleSuffix: 'No contactar', notes: 'Alek eligió excluir/no contactar desde owner_pipe.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_excluir_ack', body: `Listo: ${name} queda marcado como no contactar. No envié nada.` }); + return; + } + + const route = liveRoutes[choice]; + if (!route) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['owner-pipe-pendiente', `owner-pipe-${choice || 'sin-ruta'}`], + customFields: { staged: false, stage: 'Requiere Alek', pipeline_sugerido: choice || null, owner_action: 'pipeline_pendiente', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Requiere Alek', titleSuffix: `Pipeline ${choice || 'pendiente'}`, notes: `Alek eligió pipeline ${choice || 'pendiente'}, pero aún no está cableado para envío automático.` }); + await emitIa360N8nHandoff({ record: targetRecord, eventType: 'owner_pipe_pending', targetStage: 'Requiere Alek', priority: 'high', summary: `Pipeline ${choice || 'pendiente'} elegido para ${name}; requiere diseño/copy antes de enviar.` }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_pending_ack', body: `Ese pipeline todavía no está cableado para envío automático. Dejé a ${name} en Requiere Alek y no envié nada.` }); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['pipeline-wa-aprobado', route.tag], + customFields: { + staged: false, + stage: route.stage, + pipeline_sugerido: choice, + owner_action: `owner_pipe_${choice}`, + owner_action_at: new Date().toISOString(), + ultimo_cta_enviado: route.templateName || 'owner_pipe_slots', + }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: route.stage, titleSuffix: route.label, notes: route.summary }); + + let sendResult = { ok: false, status: 'not_sent' }; + if (choice === 'p3') { + const ok = await sendOwnerPipelineSlots({ record: targetRecord }); + sendResult = { ok, status: ok ? 'queued' : 'failed' }; + } else { + sendResult = await enqueueIa360Template({ + record: targetRecord, + label: `owner_pipe_${choice}`, + templateName: route.templateName, + templateId: route.templateId, + }); + } + const sent = !!sendResult.ok; + const queued = !sent && ['sending', 'queued', 'unknown'].includes(String(sendResult.status || '').toLowerCase()); + + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-owner-pipe', + agent: { + intent: 'owner_pipeline_selected', + action: sent ? 'pipeline_sent' : (queued ? 'pipeline_queued' : 'pipeline_send_failed'), + extracted: { pipeline: choice, stage: route.stage, route: route.label, send_status: sendResult.status || null }, + }, + }).catch(e => console.error('[ia360-owner-pipe] crm reflect:', e.message)); + emitIa360N8nHandoff({ + record: targetRecord, + eventType: route.eventType, + targetStage: route.stage, + priority: choice === 'p3' ? 'high' : 'normal', + summary: route.summary, + }).catch(e => console.error('[ia360-owner-pipe] handoff:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: sent ? 'owner_pipe_sent_ack' : (queued ? 'owner_pipe_queued_ack' : 'owner_pipe_failed_ack'), + body: sent + ? `Listo: ${name} entró a ${route.label} y ya le envié el siguiente paso.` + : queued + ? `Listo: ${name} entró a ${route.label}. Dejé el siguiente paso en cola de envío; si no aparece como enviado en ForgeChat, reviso la cola.` + : `Intenté enviar ${route.label} a ${name}, pero falló el envío${sendResult.error ? `: ${sendResult.error}` : ''}. Lo dejé en ${route.stage} para revisión.`, + }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-20260605220119 b/backend/src/routes/webhook.js.bak-20260605220119 new file mode 100644 index 0000000..3204e25 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-20260605220119 @@ -0,0 +1,3857 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nElige qué pipeline/flow quieres disparar. Tu tap es la aprobación de envío.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, envío con tu tap' }, + action: { + button: 'Elegir pipeline', + sections: [{ + title: 'IA360', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:p1`, title: 'Prospecto frío', description: 'Envía apertura IA360 100M' }, + { id: `owner_pipe:${shared.contactNumber}:p3`, title: 'Calificado / agenda', description: 'Envía horarios disponibles' }, + { id: `owner_pipe:${shared.contactNumber}:deleitar`, title: 'Cliente activo', description: 'Deleitar: por construir' }, + { id: `owner_pipe:${shared.contactNumber}:nutricion`, title: 'Nutrición', description: 'Envía reactivación suave' }, + { id: `owner_pipe:${shared.contactNumber}:aliado`, title: 'Aliado / socio', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:bni`, title: 'BNI / referido', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:amigo`, title: 'Amigo suave', description: 'Guardar y tarea humana' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Marca exclusión, sin envío' }, + ], + }], + }, + }, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + const liveRoutes = { + p1: { + label: 'Prospecto frío', + stage: 'Diagnóstico enviado', + tag: 'owner-pipe-prospecto-frio', + templateName: 'ia360_100m_img_01_dolor', + templateId: 16, + eventType: 'owner_pipe_prospecto_frio', + summary: 'Alek eligió Prospecto frío desde vCard/formulario; se seleccionó apertura IA360 100M enfocada en dolor operativo.', + }, + p3: { + label: 'Calificado / agenda', + stage: 'Agenda en proceso', + tag: 'owner-pipe-calificado-agenda', + eventType: 'owner_pipe_calificado_agenda', + summary: 'Alek eligió Calificado / agenda desde vCard/formulario; se enviaron horarios disponibles.', + }, + nutricion: { + label: 'Nutrición', + stage: 'Nutrición', + tag: 'owner-pipe-nutricion', + templateName: 'ia360_100m_06_reactivacion', + templateId: 15, + eventType: 'owner_pipe_nutricion', + summary: 'Alek eligió Nutrición desde vCard/formulario; se seleccionó reactivación suave.', + }, + }; + + if (choice === 'excluir') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['no-contactar', 'owner-pipe-excluir'], + customFields: { staged: false, stage: 'Perdido / no fit', pipeline_sugerido: 'excluir', owner_action: 'excluir', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Perdido / no fit', titleSuffix: 'No contactar', notes: 'Alek eligió excluir/no contactar desde owner_pipe.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_excluir_ack', body: `Listo: ${name} queda marcado como no contactar. No envié nada.` }); + return; + } + + const route = liveRoutes[choice]; + if (!route) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['owner-pipe-pendiente', `owner-pipe-${choice || 'sin-ruta'}`], + customFields: { staged: false, stage: 'Requiere Alek', pipeline_sugerido: choice || null, owner_action: 'pipeline_pendiente', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Requiere Alek', titleSuffix: `Pipeline ${choice || 'pendiente'}`, notes: `Alek eligió pipeline ${choice || 'pendiente'}, pero aún no está cableado para envío automático.` }); + await emitIa360N8nHandoff({ record: targetRecord, eventType: 'owner_pipe_pending', targetStage: 'Requiere Alek', priority: 'high', summary: `Pipeline ${choice || 'pendiente'} elegido para ${name}; requiere diseño/copy antes de enviar.` }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_pending_ack', body: `Ese pipeline todavía no está cableado para envío automático. Dejé a ${name} en Requiere Alek y no envié nada.` }); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['pipeline-wa-aprobado', route.tag], + customFields: { + staged: false, + stage: route.stage, + pipeline_sugerido: choice, + owner_action: `owner_pipe_${choice}`, + owner_action_at: new Date().toISOString(), + ultimo_cta_enviado: route.templateName || 'owner_pipe_slots', + }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: route.stage, titleSuffix: route.label, notes: route.summary }); + + let sendResult = { ok: false, status: 'not_sent' }; + if (choice === 'p3') { + const ok = await sendOwnerPipelineSlots({ record: targetRecord }); + sendResult = { ok, status: ok ? 'queued' : 'failed' }; + } else { + sendResult = await enqueueIa360Template({ + record: targetRecord, + label: `owner_pipe_${choice}`, + templateName: route.templateName, + templateId: route.templateId, + }); + } + const sent = !!sendResult.ok; + const queued = !sent && ['sending', 'queued', 'unknown'].includes(String(sendResult.status || '').toLowerCase()); + + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-owner-pipe', + agent: { + intent: 'owner_pipeline_selected', + action: sent ? 'pipeline_sent' : (queued ? 'pipeline_queued' : 'pipeline_send_failed'), + extracted: { pipeline: choice, stage: route.stage, route: route.label, send_status: sendResult.status || null }, + }, + }).catch(e => console.error('[ia360-owner-pipe] crm reflect:', e.message)); + emitIa360N8nHandoff({ + record: targetRecord, + eventType: route.eventType, + targetStage: route.stage, + priority: choice === 'p3' ? 'high' : 'normal', + summary: route.summary, + }).catch(e => console.error('[ia360-owner-pipe] handoff:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: sent ? 'owner_pipe_sent_ack' : (queued ? 'owner_pipe_queued_ack' : 'owner_pipe_failed_ack'), + body: sent + ? `Listo: ${name} entró a ${route.label} y ya le envié el siguiente paso.` + : queued + ? `Listo: ${name} entró a ${route.label}. Dejé el siguiente paso en cola de envío; si no aparece como enviado en ForgeChat, reviso la cola.` + : `Intenté enviar ${route.label} a ${name}, pero falló el envío${sendResult.error ? `: ${sendResult.error}` : ''}. Lo dejé en ${route.stage} para revisión.`, + }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-2acita-1780528220 b/backend/src/routes/webhook.js.bak-2acita-1780528220 new file mode 100644 index 0000000..3e9f93c --- /dev/null +++ b/backend/src/routes/webhook.js.bak-2acita-1780528220 @@ -0,0 +1,2646 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la + // tarea de EspoCRM). NO se ofrece el boton destructivo "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto, intenta ese dia primero. + if (agent.date) { + const dayAvail = await callAvail({ date: agent.date }); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + return; + } + // dia lleno → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + return; + } + const fullDay = (agent.date && agent.date !== spread.date) + ? `Ese día ya está lleno. ` : ''; + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }); + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. Le quedan ${remaining.length} reunión(es).` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-b28-1780543839 b/backend/src/routes/webhook.js.bak-b28-1780543839 new file mode 100644 index 0000000..a0aa953 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-b28-1780543839 @@ -0,0 +1,3098 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-b29-order-20260605T145907 b/backend/src/routes/webhook.js.bak-b29-order-20260605T145907 new file mode 100644 index 0000000..13f0cde --- /dev/null +++ b/backend/src/routes/webhook.js.bak-b29-order-20260605T145907 @@ -0,0 +1,3532 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado sin enviarle nada.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\n¿Qué hago?`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura != envio' }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_vcard_pipe:${shared.contactNumber}`, title: 'Pipeline WA' } }, + { type: 'reply', reply: { id: `owner_vcard_take:${shared.contactNumber}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_vcard_keep:${shared.contactNumber}`, title: 'Solo guardar' } }, + ], + }, + }, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['pipeline-wa-aprobado'], + customFields: { + staged: false, + stage: 'Nuevo contacto WA', + pipeline_sugerido: 'ia360_whatsapp_revenue', + owner_action: 'pipeline_wa', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Nuevo contacto WA', + titleSuffix: 'vCard', + notes: 'Alek aprobo meter este contacto compartido al pipeline IA360 WhatsApp Revenue.', + }); + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard-owner', + agent: { + intent: 'owner_approved_pipeline', + action: 'upsert_contact', + extracted: { + pipeline: 'IA360 WhatsApp Revenue Pipeline', + stage: 'Nuevo contacto WA', + intake_source: 'b29-vcard-whatsapp', + }, + }, + }).catch(e => console.error('[ia360-vcard] owner pipe crm:', e.message)); + emitIa360N8nHandoff({ + record: targetRecord, + eventType: 'owner_vcard_pipeline', + targetStage: 'Nuevo contacto WA', + priority: 'normal', + summary: `Alek aprobo pipeline WA para contacto compartido por vCard: ${name} (${targetContact}). No se envio mensaje automatico al contacto.`, + }).catch(e => console.error('[ia360-vcard] owner pipe handoff:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_pipe_ack', + body: `Listo: metí a ${name} (${targetContact}) en IA360 WhatsApp Revenue Pipeline / Nuevo contacto WA. No le envié mensaje todavía.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-b29-vcard-20260605T145515 b/backend/src/routes/webhook.js.bak-b29-vcard-20260605T145515 new file mode 100644 index 0000000..8046725 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-b29-vcard-20260605T145515 @@ -0,0 +1,3259 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-batchagenda-1780529842 b/backend/src/routes/webhook.js.bak-batchagenda-1780529842 new file mode 100644 index 0000000..1889461 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-batchagenda-1780529842 @@ -0,0 +1,2855 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ese dia primero. + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + const fullDay = (reqDate && reqDate !== spread.date) + ? `Ese día ya está lleno. ` : ''; + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. Le quedan ${remaining.length} reunión(es).` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-callink-1780940718 b/backend/src/routes/webhook.js.bak-callink-1780940718 new file mode 100644 index 0000000..f0ab0b6 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-callink-1780940718 @@ -0,0 +1,5382 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-cancelfix-1780521963 b/backend/src/routes/webhook.js.bak-cancelfix-1780521963 new file mode 100644 index 0000000..10bd994 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-cancelfix-1780521963 @@ -0,0 +1,2378 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + // HITL: SOLO para CANCELAR, ademas del ack+handoff, NOTIFICAR AL OWNER (Alek) + // para aprobacion conversacional. El boton "Aprobar" gatilla el webhook + // DESTRUCTIVO de cancelar; por eso NO se ofrece en reagendar (mover una + // reunion no debe poder borrarla). Reagendar conserva solo el ack+handoff + // existente (Alek mueve el evento a mano via la tarea de EspoCRM). + if (isCancel) { + try { + const c = await pool.query( + `SELECT custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [record.contact_number] + ); + const startRaw = c.rows[0]?.start || ''; + const startFmt = startRaw + ? new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)) + : 'la fecha agendada'; + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${who} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, el contacto ${who} pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${record.contact_number}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${record.contact_number}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${record.contact_number}`, title: 'Mantener' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + } + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto, intenta ese dia primero. + if (agent.date) { + const dayAvail = await callAvail({ date: agent.date }); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + return; + } + // dia lleno → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + return; + } + const fullDay = (agent.date && agent.date !== spread.date) + ? `Ese día ya está lleno. ` : ''; + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }); + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // Recupera los IDs guardados de la cita del contacto y cancela via n8n. + const { rows } = await pool.query( + `SELECT custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [targetContact] + ); + const evt = rows[0]?.evt || ''; + const zoom = rows[0]?.zoom || ''; + const startRaw = rows[0]?.start || ''; + if (!evt && !zoom) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita guardada de ${targetContact}. No cancelé nada.` }); + return; + } + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = startRaw + ? new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)) + : 'tu reunión'; + if (cancelRes.ok) { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt}. Si quieres retomar, escríbeme por aquí.` }); + await mergeContactIa360State({ waNumber: record.wa_number, contactNumber: targetContact, tags: ['cancelada-aprobada'], customFields: { ia360_booking_event_id: '', ia360_booking_zoom_id: '', ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${targetContact}.` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${targetContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL: guardamos los IDs de la cita para poder cancelarla/reagendarla luego. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-cfm-1780520261 b/backend/src/routes/webhook.js.bak-cfm-1780520261 new file mode 100644 index 0000000..068f388 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-cfm-1780520261 @@ -0,0 +1,2078 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto, intenta ese dia primero. + if (agent.date) { + const dayAvail = await callAvail({ date: agent.date }); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + return; + } + // dia lleno → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + return; + } + const fullDay = (agent.date && agent.date !== spread.date) + ? `Ese día ya está lleno. ` : ''; + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }); + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-deltaB-1780513807 b/backend/src/routes/webhook.js.bak-deltaB-1780513807 new file mode 100644 index 0000000..1e1dc69 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-deltaB-1780513807 @@ -0,0 +1,1755 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if ((agent.action === 'offer_slots' || agent.action === 'book') && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-e2-1780519288 b/backend/src/routes/webhook.js.bak-e2-1780519288 new file mode 100644 index 0000000..1929340 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-e2-1780519288 @@ -0,0 +1,2049 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if ((agent.action === 'offer_slots' || agent.action === 'book') && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-eq0-directive-20260604T210438Z b/backend/src/routes/webhook.js.bak-eq0-directive-20260604T210438Z new file mode 100644 index 0000000..a0aa953 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-eq0-directive-20260604T210438Z @@ -0,0 +1,3098 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-feedback-1780528887 b/backend/src/routes/webhook.js.bak-feedback-1780528887 new file mode 100644 index 0000000..72c6cc8 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-feedback-1780528887 @@ -0,0 +1,2666 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ese dia primero. + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + return; + } + // dia lleno → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + return; + } + const fullDay = (reqDate && reqDate !== spread.date) + ? `Ese día ya está lleno. ` : ''; + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }); + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. Le quedan ${remaining.length} reunión(es).` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-flowwire-1780515705 b/backend/src/routes/webhook.js.bak-flowwire-1780515705 new file mode 100644 index 0000000..37bec8b --- /dev/null +++ b/backend/src/routes/webhook.js.bak-flowwire-1780515705 @@ -0,0 +1,1766 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if ((agent.action === 'offer_slots' || agent.action === 'book') && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-gate-1780521499 b/backend/src/routes/webhook.js.bak-gate-1780521499 new file mode 100644 index 0000000..fd875a2 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-gate-1780521499 @@ -0,0 +1,2375 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + // HITL: ademas del ack+handoff, NOTIFICAR AL OWNER (Alek) para aprobacion + // conversacional. El path destructivo (cancelar via webhook) se gatilla solo + // si el owner pulsa "Aprobar" en su telefono. Reagendar conserva el handoff + // existente (no se cablea a un webhook destructivo de cancelar). + try { + const c = await pool.query( + `SELECT custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [record.contact_number] + ); + const startRaw = c.rows[0]?.start || ''; + const startFmt = startRaw + ? new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)) + : 'la fecha agendada'; + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${who} pidió ${isCancel ? 'cancelar' : 'reagendar'}`, + interactive: { + type: 'button', + header: { type: 'text', text: isCancel ? 'Cancelación solicitada' : 'Reagenda solicitada' }, + body: { text: `Alek, el contacto ${who} pidió ${isCancel ? 'cancelar' : 'mover'} su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${record.contact_number}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${record.contact_number}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${record.contact_number}`, title: 'Mantener' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel/reschedule failed:', ownerErr.message); + } + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto, intenta ese dia primero. + if (agent.date) { + const dayAvail = await callAvail({ date: agent.date }); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + return; + } + // dia lleno → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + return; + } + const fullDay = (agent.date && agent.date !== spread.date) + ? `Ese día ya está lleno. ` : ''; + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }); + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // Recupera los IDs guardados de la cita del contacto y cancela via n8n. + const { rows } = await pool.query( + `SELECT custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [targetContact] + ); + const evt = rows[0]?.evt || ''; + const zoom = rows[0]?.zoom || ''; + const startRaw = rows[0]?.start || ''; + if (!evt && !zoom) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita guardada de ${targetContact}. No cancelé nada.` }); + return; + } + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = startRaw + ? new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)) + : 'tu reunión'; + if (cancelRes.ok) { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt}. Si quieres retomar, escríbeme por aquí.` }); + await mergeContactIa360State({ waNumber: record.wa_number, contactNumber: targetContact, tags: ['cancelada-aprobada'], customFields: { ia360_booking_event_id: '', ia360_booking_zoom_id: '', ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${targetContact}.` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${targetContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL: guardamos los IDs de la cita para poder cancelarla/reagendarla luego. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-gate-1780944997 b/backend/src/routes/webhook.js.bak-gate-1780944997 new file mode 100644 index 0000000..da97259 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-gate-1780944997 @@ -0,0 +1,5390 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-gatefix-wantslist-1780532046 b/backend/src/routes/webhook.js.bak-gatefix-wantslist-1780532046 new file mode 100644 index 0000000..e35d5c0 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-gatefix-wantslist-1780532046 @@ -0,0 +1,2932 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. Le quedan ${remaining.length} reunión(es).` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-ia360-timeout-20260608 b/backend/src/routes/webhook.js.bak-ia360-timeout-20260608 new file mode 100644 index 0000000..35f348e --- /dev/null +++ b/backend/src/routes/webhook.js.bak-ia360-timeout-20260608 @@ -0,0 +1,5400 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-ideas-20260609T235617Z b/backend/src/routes/webhook.js.bak-ideas-20260609T235617Z new file mode 100644 index 0000000..206a865 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-ideas-20260609T235617Z @@ -0,0 +1,6233 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { + text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, + }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows: [ + { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ], + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); + // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + const sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(`${record.message_id}:direct:${targetContact}`); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-interactive-audit-20260608T145952 b/backend/src/routes/webhook.js.bak-interactive-audit-20260608T145952 new file mode 100644 index 0000000..0b00bfd --- /dev/null +++ b/backend/src/routes/webhook.js.bak-interactive-audit-20260608T145952 @@ -0,0 +1,5363 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-loglist-1780535301 b/backend/src/routes/webhook.js.bak-loglist-1780535301 new file mode 100644 index 0000000..819f03d --- /dev/null +++ b/backend/src/routes/webhook.js.bak-loglist-1780535301 @@ -0,0 +1,2939 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-multicita-1780527013 b/backend/src/routes/webhook.js.bak-multicita-1780527013 new file mode 100644 index 0000000..4f2545b --- /dev/null +++ b/backend/src/routes/webhook.js.bak-multicita-1780527013 @@ -0,0 +1,2413 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR: solo tiene sentido si HAY una cita activa guardada. Leemos los + // IDs ANTES de responder para (1) no ofrecer cancelar algo inexistente y + // (2) mandar UN solo ack limpio al contacto (sin el "le aviso a Alek" viejo + // duplicado con la notificacion al owner). Etiqueta del contacto = numero, + // NUNCA el profile_name de WhatsApp (que aqui seria "Soy Alek"). + if (isCancel) { + let evt = '', zoom = '', startRaw = ''; + try { + const c = await pool.query( + `SELECT custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [record.contact_number] + ); + evt = c.rows[0]?.evt || ''; + zoom = c.rows[0]?.zoom || ''; + startRaw = c.rows[0]?.start || ''; + } catch (lookupErr) { + console.error('[ia360-owner] cancel lookup failed:', lookupErr.message); + } + + // SIN cita activa guardada → NO molestar al owner. Responder al contacto. + if (!evt && !zoom) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No encuentro una reunión activa a tu nombre para cancelar.', + }); + return; + } + + // CON cita → UN solo ack limpio al contacto + notificacion REAL al owner. + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". Acción humana: cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + const startFmt = startRaw + ? new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)) + : 'la fecha agendada'; + await sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${record.contact_number} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${record.contact_number}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${record.contact_number}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${record.contact_number}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${record.contact_number}`, title: 'Mantener' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la + // tarea de EspoCRM). NO se ofrece el boton destructivo "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto, intenta ese dia primero. + if (agent.date) { + const dayAvail = await callAvail({ date: agent.date }); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + return; + } + // dia lleno → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + return; + } + const fullDay = (agent.date && agent.date !== spread.date) + ? `Ese día ya está lleno. ` : ''; + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }); + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // Recupera los IDs guardados de la cita del contacto y cancela via n8n. + const { rows } = await pool.query( + `SELECT custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [targetContact] + ); + const evt = rows[0]?.evt || ''; + const zoom = rows[0]?.zoom || ''; + const startRaw = rows[0]?.start || ''; + if (!evt && !zoom) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita guardada de ${targetContact}. No cancelé nada.` }); + return; + } + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = startRaw + ? new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)) + : 'tu reunión'; + if (cancelRes.ok) { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt}. Si quieres retomar, escríbeme por aquí.` }); + await mergeContactIa360State({ waNumber: record.wa_number, contactNumber: targetContact, tags: ['cancelada-aprobada'], customFields: { ia360_booking_event_id: '', ia360_booking_zoom_id: '', ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${targetContact}.` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${targetContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL: guardamos los IDs de la cita para poder cancelarla/reagendarla luego. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-owner-1780521261 b/backend/src/routes/webhook.js.bak-owner-1780521261 new file mode 100644 index 0000000..136f33f --- /dev/null +++ b/backend/src/routes/webhook.js.bak-owner-1780521261 @@ -0,0 +1,2183 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto, intenta ese dia primero. + if (agent.date) { + const dayAvail = await callAvail({ date: agent.date }); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + return; + } + // dia lleno → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + return; + } + const fullDay = (agent.date && agent.date !== spread.date) + ? `Ese día ya está lleno. ` : ''; + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }); + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-owner-pipe-20260605T152443 b/backend/src/routes/webhook.js.bak-owner-pipe-20260605T152443 new file mode 100644 index 0000000..1147033 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-owner-pipe-20260605T152443 @@ -0,0 +1,3534 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado sin enviarle nada.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\n¿Qué hago?`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura != envio' }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_vcard_pipe:${shared.contactNumber}`, title: 'Pipeline WA' } }, + { type: 'reply', reply: { id: `owner_vcard_take:${shared.contactNumber}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_vcard_keep:${shared.contactNumber}`, title: 'Solo guardar' } }, + ], + }, + }, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['pipeline-wa-aprobado'], + customFields: { + staged: false, + stage: 'Nuevo contacto WA', + pipeline_sugerido: 'ia360_whatsapp_revenue', + owner_action: 'pipeline_wa', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Nuevo contacto WA', + titleSuffix: 'vCard', + notes: 'Alek aprobo meter este contacto compartido al pipeline IA360 WhatsApp Revenue.', + }); + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard-owner', + agent: { + intent: 'owner_approved_pipeline', + action: 'upsert_contact', + extracted: { + pipeline: 'IA360 WhatsApp Revenue Pipeline', + stage: 'Nuevo contacto WA', + intake_source: 'b29-vcard-whatsapp', + }, + }, + }).catch(e => console.error('[ia360-vcard] owner pipe crm:', e.message)); + emitIa360N8nHandoff({ + record: targetRecord, + eventType: 'owner_vcard_pipeline', + targetStage: 'Nuevo contacto WA', + priority: 'normal', + summary: `Alek aprobo pipeline WA para contacto compartido por vCard: ${name} (${targetContact}). No se envio mensaje automatico al contacto.`, + }).catch(e => console.error('[ia360-vcard] owner pipe handoff:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_pipe_ack', + body: `Listo: metí a ${name} (${targetContact}) en IA360 WhatsApp Revenue Pipeline / Nuevo contacto WA. No le envié mensaje todavía.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-persona-guardrails-20260605T173040 b/backend/src/routes/webhook.js.bak-persona-guardrails-20260605T173040 new file mode 100644 index 0000000..4545bd4 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-persona-guardrails-20260605T173040 @@ -0,0 +1,4413 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const copyStatus = hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: true, + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: 'requires_alek', + approved_by: '', + approved_at: '', + reason: 'Requiere aprobación humana antes de cualquier envío externo.', + }, + guardrail: { + current_block: 'requires_human_approval', + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + 'Bloqueo actual: requiere aprobación humana; no hay envío externo al contacto.', + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.` }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.` }); + return; + } + const { flow, sequence } = found; + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-persona-seq-20260605T165643 b/backend/src/routes/webhook.js.bak-persona-seq-20260605T165643 new file mode 100644 index 0000000..245d52e --- /dev/null +++ b/backend/src/routes/webhook.js.bak-persona-seq-20260605T165643 @@ -0,0 +1,3844 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + const liveRoutes = {}; + + if (choice === 'guardar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['owner-pipe-guardar'], + customFields: { staged: true, stage: 'Capturado / Por rutear', pipeline_sugerido: 'guardar', owner_action: 'solo_guardar', owner_action_at: new Date().toISOString() }, + }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_guardar_ack', body: `Listo: ${name} queda capturado sin envío. No mandé nada al contacto.` }); + return; + } + + if (choice === 'excluir') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['no-contactar', 'owner-pipe-excluir'], + customFields: { staged: false, stage: 'Perdido / no fit', pipeline_sugerido: 'excluir', owner_action: 'excluir', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Perdido / no fit', titleSuffix: 'No contactar', notes: 'Alek eligió excluir/no contactar desde owner_pipe.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_excluir_ack', body: `Listo: ${name} queda marcado como no contactar. No envié nada.` }); + return; + } + + const route = liveRoutes[choice]; + if (!route) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['owner-pipe-pendiente', `owner-pipe-${choice || 'sin-ruta'}`], + customFields: { staged: false, stage: 'Requiere Alek', pipeline_sugerido: choice || null, owner_action: 'pipeline_pendiente', owner_action_at: new Date().toISOString() }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: 'Requiere Alek', titleSuffix: `Pipeline ${choice || 'pendiente'}`, notes: `Alek eligió pipeline ${choice || 'pendiente'}, pero aún no está cableado para envío automático.` }); + await emitIa360N8nHandoff({ record: targetRecord, eventType: 'owner_pipe_pending', targetStage: 'Requiere Alek', priority: 'high', summary: `Pipeline ${choice || 'pendiente'} elegido para ${name}; requiere diseño/copy antes de enviar.` }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_pending_ack', body: `Ese pipeline todavía no está cableado para envío automático. Dejé a ${name} en Requiere Alek y no envié nada.` }); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['pipeline-wa-aprobado', route.tag], + customFields: { + staged: false, + stage: route.stage, + pipeline_sugerido: choice, + owner_action: `owner_pipe_${choice}`, + owner_action_at: new Date().toISOString(), + ultimo_cta_enviado: route.templateName || 'owner_pipe_slots', + }, + }); + await syncIa360Deal({ record: targetRecord, targetStageName: route.stage, titleSuffix: route.label, notes: route.summary }); + + let sendResult = { ok: false, status: 'not_sent' }; + if (choice === 'p3') { + const ok = await sendOwnerPipelineSlots({ record: targetRecord }); + sendResult = { ok, status: ok ? 'queued' : 'failed' }; + } else { + sendResult = await enqueueIa360Template({ + record: targetRecord, + label: `owner_pipe_${choice}`, + templateName: route.templateName, + templateId: route.templateId, + }); + } + const sent = !!sendResult.ok; + const queued = !sent && ['sending', 'queued', 'unknown'].includes(String(sendResult.status || '').toLowerCase()); + + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-owner-pipe', + agent: { + intent: 'owner_pipeline_selected', + action: sent ? 'pipeline_sent' : (queued ? 'pipeline_queued' : 'pipeline_send_failed'), + extracted: { pipeline: choice, stage: route.stage, route: route.label, send_status: sendResult.status || null }, + }, + }).catch(e => console.error('[ia360-owner-pipe] crm reflect:', e.message)); + emitIa360N8nHandoff({ + record: targetRecord, + eventType: route.eventType, + targetStage: route.stage, + priority: choice === 'p3' ? 'high' : 'normal', + summary: route.summary, + }).catch(e => console.error('[ia360-owner-pipe] handoff:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: sent ? 'owner_pipe_sent_ack' : (queued ? 'owner_pipe_queued_ack' : 'owner_pipe_failed_ack'), + body: sent + ? `Listo: ${name} entró a ${route.label} y ya le envié el siguiente paso.` + : queued + ? `Listo: ${name} entró a ${route.label}. Dejé el siguiente paso en cola de envío; si no aparece como enviado en ForgeChat, reviso la cola.` + : `Intenté enviar ${route.label} a ${name}, pero falló el envío${sendResult.error ? `: ${sendResult.error}` : ''}. Lo dejé en ${route.stage} para revisión.`, + }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-aiagent-20260602T225239Z b/backend/src/routes/webhook.js.bak-pre-aiagent-20260602T225239Z new file mode 100644 index 0000000..9b937cd --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-aiagent-20260602T225239Z @@ -0,0 +1,1550 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId] || answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, reunión confirmada.\n\nHora: ${confirmedTime} (CDMX)\nZoom: ${booking.zoomJoinUrl}\n\nTambién quedó en Google Calendar de Alek.`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-approvesend-20260609T221906Z b/backend/src/routes/webhook.js.bak-pre-approvesend-20260609T221906Z new file mode 100644 index 0000000..2620b2f --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-approvesend-20260609T221906Z @@ -0,0 +1,5945 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-brainv2-canary-20260609T190449Z b/backend/src/routes/webhook.js.bak-pre-brainv2-canary-20260609T190449Z new file mode 100644 index 0000000..5efc07e --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-brainv2-canary-20260609T190449Z @@ -0,0 +1,5814 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-coldstart-fix-20260603T010301Z b/backend/src/routes/webhook.js.bak-pre-coldstart-fix-20260603T010301Z new file mode 100644 index 0000000..3f38487 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-coldstart-fix-20260603T010301Z @@ -0,0 +1,1753 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if (agent.action === 'offer_slots' && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId] || answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-confirmcopy-20260602T232502Z b/backend/src/routes/webhook.js.bak-pre-confirmcopy-20260602T232502Z new file mode 100644 index 0000000..38c9080 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-confirmcopy-20260602T232502Z @@ -0,0 +1,1707 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|cambiar|otro d[ií]a|otra hora|otro horario|posponer|recorrer|adelantar|cancel/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if (agent.action === 'offer_slots' && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId] || answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, reunión confirmada.\n\nHora: ${confirmedTime} (CDMX)\nZoom: ${booking.zoomJoinUrl}\n\nTambién quedó en Google Calendar de Alek.`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-consola-20260610T152850Z b/backend/src/routes/webhook.js.bak-pre-consola-20260610T152850Z new file mode 100644 index 0000000..aac868a --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-consola-20260610T152850Z @@ -0,0 +1,6391 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { + text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, + }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows: [ + { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ], + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); + // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + const sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(`${record.message_id}:direct:${targetContact}`); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-gbrain-20260611T221925Z b/backend/src/routes/webhook.js.bak-pre-gbrain-20260611T221925Z new file mode 100644 index 0000000..8aed475 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-gbrain-20260611T221925Z @@ -0,0 +1,8609 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +// G-LIVE: números QA del harness E2E (pruebas sintéticas autorizadas). Longitud +// exacta de 13 dígitos (521 + 99900XXXXX): un '\d*' laxo dejaría pasar móviles +// reales de la lada 999 (Mérida) como si fueran QA. +const IA360_QA_NUMBER_RE = /^52199900\d{5}$/; + +// G-LIVE: pregunta que requiere DATOS VIVOS del portal del cliente (saldos, listas, +// estatus). Lectura de estado => handoff explícito, nunca inventar. La guía de uso +// ("¿cómo subo información al portal?") NO matchea: esa sí la responde el agente. +function isIa360PortalLiveDataQuestion(body) { + const t = String(body || '').toLowerCase(); + if (!/(portal|plataforma)/.test(t)) return false; + return /(cu[aá]nt[oa]s?\b|cu[aá]les\b|qu[eé] (hay|tiene|dice|muestra|aparece)|lista(do)?\s+de|estatus|estado de|sald[oa]s?|pendientes? de pago|cargad[oa]s?|registrad[oa]s?)/.test(t); +} + +// G-LIVE: perfil de comunicación para el agente IA. Viaja como `role` en el payload; +// el nodo Normalize del workflow n8n lo expone al modelo como contact.role (sin tocar +// n8n publicado). do_not_pitch => tono ejecutivo: corto, negocio, control/riesgo. +function buildIa360ClienteActivoBetaRoleHint(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const role = beta.contact_role || cf.project_role || cf.rol_comite || 'cliente activo beta'; + const noPitchRaw = beta.do_not_pitch != null ? beta.do_not_pitch : cf.do_not_pitch; + const noPitch = noPitchRaw === true || noPitchRaw === 1 + || /^(true|1|s[ií]|yes)$/i.test(String(noPitchRaw || '')) + || /cfo|champion|finanzas/i.test(String(role)); + const parts = [String(role), 'cliente activo en beta supervisada']; + if (noPitch) parts.push('do_not_pitch: PROHIBIDO vender, pitchear u ofrecer nuevos servicios'); + parts.push('estilo ejecutivo: respuesta corta, implicación de negocio, control y riesgo, sin detalle técnico salvo que lo pida'); + return parts.join(' | '); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact, captureOnly = false }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + if (captureOnly) { + // G-LIVE: la captura de memoria NUNCA es respuesta al cliente. El caller (rama + // cliente activo/beta de handleIa360FreeText) responde con el agente IA; aquí + // solo se aprende en segundo plano. + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=capture_only', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return false; + } + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run -> fallback_required', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + // Important: dry-run memory learning is NOT a customer response. Returning true + // made the parent handler believe the message was handled, which produced the + // active-client silence bug. Return false so handleIa360FreeText sends the + // universal holding fallback + owner alert instead of leaving WhatsApp quiet. + return false; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +// G-C: el nombre del introductor viene de push name / vCard (texto controlado por +// el remitente). Se sanitiza antes de persistir: sin caracteres de control ni +// saltos de línea, sin llaves de placeholder, espacios colapsados y tope de 60 +// caracteres. Devuelve null si no queda nada usable. +function sanitizeIa360IntroName(raw) { + const clean = String(raw || '') + .replace(/[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ' ') + .replace(/[{}]/g, '') + .replace(/\s+/g, ' ') + .trim(); + // Corte por code points (no por unidades UTF-16): un emoji en la frontera de + // los 60 caracteres no deja un surrogate suelto que rompa el jsonb al persistir. + const capped = Array.from(clean).slice(0, 60).join('').trim(); + if (!capped) return null; + if (!/[\p{L}]/u.test(capped)) return null; // sin letras (solo dígitos/símbolos) no sirve como nombre + return capped; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + // quien_intro (D6): si el vCard lo comparte un CONTACTO (no el owner), esa + // persona es quien hizo la introducción. Se guarda el NOMBRE para que el + // opener referido_contexto pueda decir "nos presentó X". Si lo manda el owner, + // el dato queda pendiente (el placeholder {{quien_intro}} bloquea el copy). + // G-C: sanitizado (push name inyectable), sin auto-introducción (vCard propio) + // y sin pisar un quien_intro ya capturado. + let quienIntro = null; + const sharerIsSelf = normalizePhone(record.contact_number || '') === normalizePhone(shared.contactNumber || ''); + if (record.contact_number && !sharerIsSelf && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + try { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(record.contact_number)] + ); + quienIntro = sanitizeIa360IntroName(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || ''); + if (quienIntro) { + const { rows: existingRows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(shared.contactNumber)] + ); + if (String(existingRows[0]?.quien_intro || '').trim()) quienIntro = null; // ya hay introductor registrado: no pisar + } + } catch (e) { + console.error('[ia360-vcard] quien_intro lookup:', e.message); + quienIntro = null; + } + } + const customFields = { + ...(quienIntro ? { quien_intro: quienIntro } : {}), + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +// ============================================================================ +// G-WIN — Quick-win "Mapa de cartera" (Pipeline 7 "Champions — Adopción y +// expansión", persona cliente activo / CFO). Patrón Revenue OS P5: máquina de +// estados en contacts.custom_fields.ia360_cartera_state ('' → esperando_tabla +// → mapa_entregado), handlers gateados que CORTAN el embudo, egress único vía +// enqueueIa360Text / sendIa360DirectText → sendQueue. +// PASO 1 (texto cartera/saldos que no cuadran) → Hallazgo / Impacto / Dato +// faltante (pide la tabla EN TEXTO; el bot no lee imágenes) / +// Siguiente acción. SIN pitch y SIN agenda. +// PASO 2 (tabla pegada en texto) → mapa estructurado al contacto + nota +// completa en su deal P7 + cola ia360_docs_sync + readout al owner + +// deal a "Quick win entregado" (solo hacia adelante). +// GUARDRAIL: nunca agenda automática, no nutrición, no insistencia; si el +// mensaje no es de cartera, el flujo NO se activa y el agente genérico sigue. +// ============================================================================ +const CHAMPIONS_PIPELINE_NAME = 'Champions — Adopción y expansión'; +const CARTERA_STAGE_VALIDACION = 'Validación en curso'; +const CARTERA_STAGE_QUICKWIN = 'Quick win entregado'; +const CARTERA_FORMATO_TABLA = 'Cliente | Saldo en portal | Saldo correcto | Fecha de corte | Responsable'; + +const IA360_CARTERA_COPY = { + paso1: [ + 'Gracias por el aviso. Lo dejo ordenado:', + '', + '*Hallazgo:* los saldos que muestra el portal no cuadran con los saldos reales de cartera; hoy la corrección depende de revisiones manuales y la diferencia no se ve en un solo lugar.', + '', + '*Impacto:* mientras el portal muestre saldos incorrectos, cobranza trabaja con cifras que el cliente puede rebatir y el seguimiento pierde confiabilidad.', + '', + '*Dato faltante:* mándame la tabla aquí mismo, en texto, una línea por cuenta con este formato:', + CARTERA_FORMATO_TABLA, + 'Importante: no puedo leer imágenes ni archivos adjuntos; si la tienes en foto o en Excel, pégamela como texto.', + '', + '*Siguiente acción:* en cuanto la reciba, la convierto en tu mapa de cartera (cuenta → saldo portal → saldo correcto → fecha de corte → responsable → siguiente acción) y lo dejo registrado para Alek.', + ].join('\n'), + pideTexto: [ + 'Recibí tu archivo, pero no puedo leer imágenes ni documentos adjuntos.', + '¿Me pegas la tabla aquí mismo en texto? Una línea por cuenta:', + CARTERA_FORMATO_TABLA, + ].join('\n'), + recordatorioFormato: [ + 'Va, sigo pendiente de la tabla para armar el mapa. Pégala aquí en texto, una línea por cuenta:', + CARTERA_FORMATO_TABLA, + ].join('\n'), +}; + +// Persona cliente activo / CFO: reúsa el helper beta (Andrés) y el perfil +// persona-first (QA y contactos nuevos). El owner JAMÁS entra a este flujo. +function ia360IsClienteActivoCartera(contact) { + if (!contact) return false; + if (isIa360ClienteActivoBetaContact(contact)) return true; + const cf = contact.custom_fields || {}; + const rel = cf?.ia360_persona_first?.classification?.relationship_context || ''; + const personaCtx = String(cf.persona_context || '').toLowerCase(); + const tags = Array.isArray(contact.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return rel === 'cliente_activo' + || personaCtx === 'cliente activo' + || tags.includes('persona:cliente_activo'); +} + +// Disparador del PASO 1: cartera/cobranza explícita, o "saldos" acompañado de +// señal de descuadre. Mantenerlo angosto: un tema no-cartera NO debe activar +// el flujo (gate del goal). +const IA360_CARTERA_TRIGGER_RE = /\b(cartera|cobranza|cuentas?\s+por\s+cobrar)\b/i; +const IA360_CARTERA_SALDOS_RE = /\bsaldos?\b/i; +const IA360_CARTERA_DESCUADRE_RE = /no\s+cuadra|descuadr|incorrect|equivocad|diferenc|portal|\bmal\b/i; + +function ia360EsMensajeCartera(body) { + const t = String(body || ''); + return IA360_CARTERA_TRIGGER_RE.test(t) + || (IA360_CARTERA_SALDOS_RE.test(t) && IA360_CARTERA_DESCUADRE_RE.test(t)); +} + +// Parser de la tabla pegada en texto. Separadores: | ; tab. Coma solo si la +// línea no trae montos con coma de millares ("1,250,000"). Salta encabezados. +function parseCarteraTabla(text) { + const rows = []; + const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean); + for (const line of lines) { + let parts = null; + if (/[|;\t]/.test(line)) parts = line.split(/[|;\t]/); + else if (line.includes(',') && !/\d,\d{3}/.test(line)) parts = line.split(','); + if (!parts) continue; + parts = parts.map(p => p.trim()).filter(p => p !== ''); + if (parts.length < 4) continue; + const low = line.toLowerCase(); + if (/cliente|cuenta/.test(low) && /saldo/.test(low)) continue; // encabezado + rows.push({ + cuenta: parts[0], + saldo_portal: parts[1], + saldo_correcto: parts[2], + fecha_corte: parts[3], + responsable: parts[4] || 'por confirmar', + }); + } + return rows; +} + +function carteraMonto(s) { + const limpio = String(s || '').replace(/[^0-9.\-]/g, ''); + if (!limpio || limpio === '-' || limpio === '.') return null; + const n = Number(limpio); + return Number.isFinite(n) ? n : null; +} + +function carteraFormatoMonto(n) { + if (n === null || !Number.isFinite(n)) return null; + const negativo = n < 0; + const [ent, dec] = Math.abs(n).toFixed(2).split('.'); + return `${negativo ? '-' : ''}$${ent.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${dec}`; +} + +// Mapa estructurado: cuenta → saldo portal → saldo correcto → diferencia → +// fecha de corte → responsable → siguiente acción. +function buildCarteraMapa(rows) { + const bloques = []; + let diferenciaTotal = 0; + let cuentasConDescuadre = 0; + rows.forEach((r, i) => { + const portal = carteraMonto(r.saldo_portal); + const correcto = carteraMonto(r.saldo_correcto); + const dif = portal !== null && correcto !== null ? correcto - portal : null; + if (dif !== null) { + diferenciaTotal += dif; + if (dif !== 0) cuentasConDescuadre += 1; + } + bloques.push([ + `${i + 1}) Cuenta: ${r.cuenta}`, + ` - Saldo en portal: ${carteraFormatoMonto(portal) || r.saldo_portal}`, + ` - Saldo correcto: ${carteraFormatoMonto(correcto) || r.saldo_correcto}`, + ` - Diferencia: ${dif !== null ? carteraFormatoMonto(dif) : 'por calcular'}`, + ` - Fecha de corte: ${r.fecha_corte}`, + ` - Responsable: ${r.responsable}`, + ` - Siguiente acción: corregir el saldo en el portal y confirmarlo con ${r.responsable} antes del próximo corte.`, + ].join('\n')); + }); + const texto = [ + '*Mapa de cartera — saldos por corregir*', + '', + bloques.join('\n\n'), + '', + `Cuentas con descuadre: ${cuentasConDescuadre} de ${rows.length} · Diferencia acumulada: ${carteraFormatoMonto(diferenciaTotal) || 'por calcular'}`, + '', + 'Ya quedó registrado para Alek con el detalle completo. Cuando el portal refleje los saldos correctos, este mapa sirve para confirmarlo cuenta por cuenta.', + ].join('\n'); + return { texto, diferenciaTotal, cuentasConDescuadre }; +} + +// Movimiento de deal dedicado a Pipeline 7 (clon del patrón syncRevenueOsDeal: +// create-or-move, solo hacia adelante por posición; NO toca otros pipelines). +async function syncCarteraChampionsDeal({ record, targetStageName, notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [CHAMPIONS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const title = `IA360 · ${contactName} · Quick win cartera`; + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name, title }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + notes = $3, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $4`, + [finalStageId, shouldMove ? finalStatus : existing.status, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name, title: existing.title }; +} + +// Media (imagen/documento) durante esperando_tabla → pedir la versión en texto. +// El bot no descarga ni interpreta el archivo; solo guía al contacto. +async function handleCarteraMediaInbound(record) { + try { + if (!record || record.direction !== 'incoming') return false; + if (record.message_type !== 'image' && record.message_type !== 'document') return false; + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || !ia360IsClienteActivoCartera(contact)) return false; + if ((contact.custom_fields?.ia360_cartera_state || '') !== 'esperando_tabla') return false; + await enqueueIa360Text({ record, label: 'ia360_cartera_pide_texto', body: IA360_CARTERA_COPY.pideTexto }); + return true; + } catch (err) { + console.error('[cartera] media handler error (no route):', err.message); + return false; + } +} + +// Readout al owner tras entregar el mapa (PASO 2). ownerBudget=false: un quick +// win entregado siempre se reporta. +function buildCarteraOwnerReadout({ record, contactName, deal, mapa, rows }) { + return [ + `IA360 · Quick win cartera — ${contactName || 'contacto'} (${maskIa360Number(record.contact_number)})`, + '', + `El contacto entregó su tabla de cartera (${rows.length} ${rows.length === 1 ? 'cuenta' : 'cuentas'}) y le devolví el mapa estructurado.`, + `- Cuentas con descuadre: ${mapa.cuentasConDescuadre} · Diferencia acumulada: ${carteraFormatoMonto(mapa.diferenciaTotal) || 'por calcular'}`, + deal ? `- Deal: «${deal.title || 'sin título'}» → ${deal.stage} (P7 Champions).` : '- Deal: no se encontró deal en P7 (revisar).', + '- Mapa encolado a ia360_docs_sync (destino AlekContenido).', + '', + 'No envié pitch ni agenda; el flujo quedó en modo quick win.', + ].join('\n'); +} + +// PASO 1 + PASO 2 — texto libre. Va DESPUÉS de Revenue OS y ANTES del agente +// genérico en el dispatch; devuelve true para CORTAR el embudo (guardrail: el +// agente no debe responder encima ni empujar agenda). +async function handleCarteraFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || !ia360IsClienteActivoCartera(contact)) return false; + const state = contact.custom_fields?.ia360_cartera_state || ''; + + // PASO 2 — esperando la tabla. + if (state === 'esperando_tabla') { + const rows = parseCarteraTabla(body); + if (rows.length > 0) { + const mapa = buildCarteraMapa(rows); + const deal = await syncCarteraChampionsDeal({ + record, + targetStageName: CARTERA_STAGE_QUICKWIN, + notes: `PASO 2 mapa de cartera: tabla recibida (${rows.length} cuentas). Quick win entregado.\nTabla original:\n${body}\n\n${mapa.texto}`, + }).catch(e => { console.error('[cartera] deal quick win:', e.message); return null; }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['cartera-quickwin-entregado'], + customFields: { + ia360_cartera_state: 'mapa_entregado', + ia360_cartera_mapa_at: new Date().toISOString(), + ia360_cartera_cuentas: rows.length, + ia360_cartera_tabla_raw: body, + }, + }).catch(e => console.error('[cartera] estado mapa_entregado:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_cartera_mapa', body: mapa.texto }); + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) + VALUES (NULL, $1, $2, 'AlekContenido')`, + [ + `Mapa de cartera — ${contact.name || record.contact_number} (${new Date().toISOString().slice(0, 10)})`, + `${mapa.texto}\n\n---\nTabla original pegada por el contacto:\n${body}`, + ] + ).catch(e => console.error('[cartera] docs_sync:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_cartera_readout', + body: buildCarteraOwnerReadout({ record, contactName: contact.name, deal, mapa, rows }), + targetContact: record.contact_number, + }).catch(e => console.error('[cartera] owner readout:', e.message)); + return true; + } + // Sin tabla todavía: si insiste en el tema, recordamos el formato; si + // habla de otra cosa, el agente genérico responde (respuesta siempre útil). + if (ia360EsMensajeCartera(body)) { + await enqueueIa360Text({ record, label: 'ia360_cartera_formato', body: IA360_CARTERA_COPY.recordatorioFormato }); + return true; + } + return false; + } + + // PASO 1 — disparo del flujo (solo tema cartera; gate del goal). + if (state !== 'mapa_entregado' && ia360EsMensajeCartera(body)) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['cartera-quickwin'], + customFields: { + ia360_cartera_state: 'esperando_tabla', + ia360_cartera_dolor: body, + ia360_cartera_paso1_at: new Date().toISOString(), + }, + }); + await syncCarteraChampionsDeal({ + record, + targetStageName: CARTERA_STAGE_VALIDACION, + notes: `PASO 1 mapa de cartera: el contacto reportó saldos que no cuadran. Mensaje: ${body}`, + }).catch(e => console.error('[cartera] deal paso 1:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_cartera_paso1', body: IA360_CARTERA_COPY.paso1 }); + return true; + } + return false; + } catch (err) { + console.error('[cartera] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record, vars = null) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + // G-COLD: para índices distintos de '1', vars (valor real del flujo, p.ej. + // quien_intro) tiene prioridad SOLO si trae contenido; si no, se conserva + // el fallback samples[k] || ' ' de los flujos existentes. + parameters: indexes.map(k => { + if (k === '1') return { type: 'text', text: firstNameForTemplate(record) }; + const v = vars?.[k]; + const hasVar = v != null && String(v).trim() !== ''; + return { type: 'text', text: hasVar ? String(v) : String(samples[k] || ' ') }; + }), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null, vars = null, allowTextFallback = true }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record, vars); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + // G-COLD: fuera de ventana el fallback a texto libre está PROHIBIDO + // (allowTextFallback:false): Meta lo rechazaría y aquí se reportaría un + // éxito falso; además renderiza con samples, no con vars reales. + if (!allowTextFallback) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> sin fallback (allowTextFallback=false)`); + return { ok: false, status: 'template_invalid', error: v.errors.join('; ') }; + } + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName, roleHint = null }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + // G-LIVE: el Normalize del workflow mapea `role` a contact.role dentro del + // agent_input — perfil de comunicación visible para el modelo sin tocar n8n. + ...(roleHint ? { role: roleHint } : {}), + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + step2: { + si_pregunta: 'Va la pregunta: si este mensaje te hubiera llegado sin conocer a Alek, ¿se entiende qué es IA360 y qué puedo y no puedo hacer como IA, o hay algo que te haría desconfiar? Dímelo con toda franqueza; para eso es esta prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, + metaTemplateName: 'ia360_beta_architectura', // G-COLD: template frío con los mismos botones del opener + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_architectura:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_architectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_architectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + step2: { + si_pregunta: 'Gracias. ¿Cómo se siente recibir un mensaje así de una IA: natural, raro o invasivo? Lo que me digas se lo paso a Alek tal cual, sin suavizarlo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, + metaTemplateName: 'ia360_beta_feedback', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_feedback:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_feedback:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_feedback:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + step2: { + si_a_ver: 'Va. Pregúntame algo que Alek y tú hayan platicado o trabajado antes, y te digo qué tengo registrado. Tú pones la prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, + metaTemplateName: 'ia360_beta_memoria', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_memoria:si_a_ver', title: 'Sí, a ver' }, + { id: 'seq_beta_memoria:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_memoria:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + step2: { + pregunta: '¿Qué te contó la persona que nos presentó sobre lo que hace Alek, y qué te llamó la atención para aceptar la introducción? Con eso evitamos mandarte algo fuera de lugar.', + }, + draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + // G-COLD: el {{2}} del template es quien_intro; en frío se exige el dato + // antes de aprobar (ver handleIa360OwnerApproveSend). + metaTemplateName: 'ia360_referido_contexto', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_contexto:pregunta', title: 'Hazme una pregunta' }, + { id: 'seq_referido_contexto:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_contexto:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + step2: { + si_cuentame: 'Va la versión completa en corto: IA360 conecta WhatsApp, CRM, agenda y memoria de clientes para que el seguimiento no dependa de la memoria de nadie. ¿En tu operación dónde se cae más el seguimiento hoy: mensajes, CRM o agenda?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, + metaTemplateName: 'ia360_referido_oneliner', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_oneliner:si_cuentame', title: 'Sí, cuéntame más' }, + { id: 'seq_referido_oneliner:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_oneliner:ahora_no', title: 'Por ahora no' }, + ], + }, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + step2: { + pregunta: 'Claro, pregunta con confianza: qué hace IA360, cómo trabaja Alek o qué implicaría la llamada. Te respondo aquí mismo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, + metaTemplateName: 'ia360_referido_permiso_agenda_v2', + // G-COLD: el template v2 ya trae los mismos botones del opener; el alias + // mapea su afirmativo a la rama de horarios. + templateAliasOption: 'horarios', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_permiso_agenda:horarios', title: 'Proponme horarios' }, + { id: 'seq_referido_permiso_agenda:pregunta', title: 'Primero una pregunta' }, + { id: 'seq_referido_permiso_agenda:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + step2: { + si_pregunta: 'Gracias. ¿Qué tipo de clientes atiendes hoy y dónde los ves sufrir más: WhatsApp desordenado, CRM sin seguimiento o procesos repetidos a mano? Con eso mapeamos el fit.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, + metaTemplateName: 'ia360_aliado_mapa_colaboracion_v2', // G-COLD: v2 con botones QUICK_REPLY del opener + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_mapa_colaboracion:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_mapa_colaboracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_mapa_colaboracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + step2: { + si_pregunta: 'Va: cuando un cliente tuyo ya necesita ordenar WhatsApp, CRM o seguimiento, ¿qué señales lo delatan primero? Con eso definimos juntos a quién sí presentarle IA360.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, + metaTemplateName: 'ia360_aliado_criterios_fit', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_criterios_fit:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_criterios_fit:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_criterios_fit:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + step2: { + si_comparte: 'Va el caso NDA-safe en corto: una empresa de servicios perdía seguimiento entre WhatsApp y su CRM; con IA360 cada conversación queda registrada, el pipeline se mueve solo y el dueño revisa su semana en un tablero. ¿Le haría sentido a alguno de tus clientes?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, + metaTemplateName: 'ia360_aliado_caso_reventa', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_caso_reventa:si_comparte', title: 'Sí, compártelo' }, + { id: 'seq_aliado_caso_reventa:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_caso_reventa:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + step2: { + si_cuento: 'Te leo. Cuéntame el avance, la fricción o el pendiente con el detalle que quieras; se lo dejo a Alek con contexto hoy mismo.', + todo_bien: 'Qué bueno. Le paso a Alek que todo va en orden. Cualquier cosa que surja, me escribes por aquí y se lo pongo enfrente.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, + metaTemplateName: 'ia360_cliente_readout', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_readout:si_cuento', title: 'Sí, te cuento' }, + { id: 'seq_cliente_readout:todo_bien', title: 'Todo va bien' }, + { id: 'seq_cliente_readout:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + step2: { + hay_tema: 'Cuéntame el tema con el detalle que quieras; se lo paso a Alek hoy mismo con prioridad para que no se quede atorado.', + todo_orden: 'Perfecto, me da gusto. Le confirmo a Alek que no hay pendientes de su lado. Aquí sigo si surge algo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, + metaTemplateName: 'ia360_cliente_soporte', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_soporte:hay_tema', title: 'Sí, hay un tema' }, + { id: 'seq_cliente_soporte:todo_orden', title: 'Todo en orden' }, + { id: 'seq_cliente_soporte:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te escribo de su parte porque tú y Alek ya tienen un proyecto andando, y Alek quiere ubicar dónde estaría el siguiente paso con más impacto, sin empujarte nada fuera de tiempo. De estas áreas, ¿cuál te quita más tiempo hoy?`, + requiresLiveDeal: true, + metaTemplateName: 'ia360_cliente_expansion', // G-COLD: en frío sale como QUICK_REPLY (la lista vive en el flujo caliente) + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cliente_expansion:whatsapp', title: 'WhatsApp y mensajes' }, + { id: 'seq_cliente_expansion:crm', title: 'CRM y clientes' }, + { id: 'seq_cliente_expansion:datos', title: 'Datos y reportes' }, + { id: 'seq_cliente_expansion:agenda', title: 'Agenda y citas' }, + { id: 'seq_cliente_expansion:seguimiento', title: 'Seguimiento de ventas' }, + { id: 'seq_cliente_expansion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte antes de mandarte una demo genérica: prefiere ubicar primero dónde habría valor real para tu operación. De estas áreas, ¿dónde sientes el cuello de botella que más mueve la aguja?`, + metaTemplateName: 'ia360_sponsor_diagnostico', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_sponsor_diagnostico:operacion', title: 'Operación' }, + { id: 'seq_sponsor_diagnostico:ventas', title: 'Ventas' }, + { id: 'seq_sponsor_diagnostico:datos', title: 'Datos y reportes' }, + { id: 'seq_sponsor_diagnostico:seguimiento', title: 'Seguimiento' }, + { id: 'seq_sponsor_diagnostico:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?`, + metaTemplateName: 'ia360_sponsor_fuga_valor', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir fuga', + options: [ + { id: 'seq_sponsor_fuga_valor:tiempo', title: 'Tiempo perdido' }, + { id: 'seq_sponsor_fuga_valor:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_sponsor_fuga_valor:datos', title: 'Datos poco confiables' }, + { id: 'seq_sponsor_fuga_valor:decisiones', title: 'Decisiones lentas' }, + { id: 'seq_sponsor_fuga_valor:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + step2: { + si_manda: 'Va el caso en corto: una operación que dependía de WhatsApp y Excel perdía seguimiento y visibilidad; con IA360 los mensajes alimentan el CRM, el pipeline se mueve solo y la dirección revisa su semana en un tablero. Si quieres, Alek te aterriza el paralelo con tu operación en una llamada corta.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, + metaTemplateName: 'ia360_sponsor_caso_ndasafe', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_sponsor_caso_ndasafe:si_manda', title: 'Sí, mándalo' }, + { id: 'seq_sponsor_caso_ndasafe:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_sponsor_caso_ndasafe:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja ayudando a equipos comerciales y casi siempre el problema aparece en uno de tres lugares. En tu equipo, ¿cuál duele más hoy?`, + metaTemplateName: 'ia360_comercial_pipeline', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_pipeline:leads', title: 'Leads que no llegan' }, + { id: 'seq_comercial_pipeline:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_comercial_pipeline:contexto', title: 'WhatsApp sin contexto' }, + { id: 'seq_comercial_pipeline:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy?`, + metaTemplateName: 'ia360_comercial_wa_crm', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_wa_crm:historial', title: 'Historial de clientes' }, + { id: 'seq_comercial_wa_crm:seguimiento', title: 'Seguimiento' }, + { id: 'seq_comercial_wa_crm:prioridad', title: 'Prioridad de leads' }, + { id: 'seq_comercial_wa_crm:datos', title: 'Datos para decidir' }, + { id: 'seq_comercial_wa_crm:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?`, + metaTemplateName: 'ia360_comercial_motor_prospeccion', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_motor_prospeccion:segmento', title: 'Segmento claro' }, + { id: 'seq_comercial_motor_prospeccion:mensaje', title: 'Mensaje repetible' }, + { id: 'seq_comercial_motor_prospeccion:seguimiento', title: 'Seguimiento medible' }, + { id: 'seq_comercial_motor_prospeccion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja con equipos de finanzas que terminan operando a mano porque no pueden confiar rápido en sus datos. En tu caso, ¿dónde está el mayor dolor hoy?`, + metaTemplateName: 'ia360_cfo_control', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cfo_control:cartera', title: 'Cartera' }, + { id: 'seq_cfo_control:comisiones', title: 'Comisiones' }, + { id: 'seq_cfo_control:reportes', title: 'Reportes' }, + { id: 'seq_cfo_control:conciliacion', title: 'Conciliación' }, + { id: 'seq_cfo_control:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + step2: { + respondo: 'Te leo. Cuéntame qué información te cuesta más tener confiable y a tiempo (cartera, cobranza, reportes), y se la paso a Alek aterrizada.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + metaTemplateName: 'ia360_cfo_cartera_datos', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cfo_cartera_datos:respondo', title: 'Te respondo aquí' }, + { id: 'seq_cfo_cartera_datos:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_cfo_cartera_datos:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + metaTemplateName: 'ia360_cfo_comisiones', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_cfo_comisiones:reglas', title: 'Reglas manuales' }, + { id: 'seq_cfo_comisiones:excepciones', title: 'Excepciones' }, + { id: 'seq_cfo_comisiones:datos', title: 'Datos que no cuadran' }, + { id: 'seq_cfo_comisiones:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + step2: { + mapa: 'Va el mapa corto: WhatsApp Cloud API → ForgeChat (bandeja y reglas) → n8n (orquestación) → CRM y memoria por contacto. Todo con permisos mínimos, trazabilidad de cada mensaje y aprobación humana antes de cualquier envío sensible. Si quieres el detalle técnico completo, Alek te lo manda directo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, + metaTemplateName: 'ia360_tecnico_arquitectura', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_arquitectura:mapa', title: 'Mándame el mapa' }, + { id: 'seq_tecnico_arquitectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_arquitectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada?`, + metaTemplateName: 'ia360_tecnico_rollback', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir riesgo', + options: [ + { id: 'seq_tecnico_rollback:permisos', title: 'Permisos' }, + { id: 'seq_tecnico_rollback:datos', title: 'Datos' }, + { id: 'seq_tecnico_rollback:trazabilidad', title: 'Trazabilidad' }, + { id: 'seq_tecnico_rollback:reversibilidad', title: 'Reversibilidad' }, + { id: 'seq_tecnico_rollback:dependencia', title: 'Dependencia operativa' }, + { id: 'seq_tecnico_rollback:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + step2: { + respondo: 'Te leo. Dime qué condición tendría que cumplirse para que la prueba te parezca segura (permisos, alcance, datos, reversibilidad) y la registro tal cual para Alek.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, + metaTemplateName: 'ia360_tecnico_integracion', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_integracion:respondo', title: 'Te respondo aquí' }, + { id: 'seq_tecnico_integracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_integracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +// Openers v2: saludo con primer nombre (D9). Limpia el sufijo de QA y toma el +// primer token; si no hay nada usable devuelve el valor original. +function ia360FirstNameFrom(name) { + const raw = String(name || '').trim().replace(/\s+WhatsApp IA360$/i, '').trim(); + return raw.split(/\s+/).filter(Boolean)[0] || raw; +} + +// Openers v2: arma el objeto `interactive` de un opener desde sequence.openerOptions +// (kind 'buttons' ≤3 opciones, kind 'list' 4+). Sin header ni footer: el copy +// aprobado por Alek va fiel en body.text. Devuelve null si la secuencia no tiene +// openerOptions (esas siguen saliendo como texto plano). +function buildIa360OpenerInteractive({ sequence, bodyText }) { + const opts = sequence && sequence.openerOptions; + if (!opts || !Array.isArray(opts.options) || !opts.options.length) return null; + if (opts.kind === 'list') { + return { + type: 'list', + body: { text: bodyText }, + action: { + button: opts.button || 'Elegir', + sections: [{ + title: 'Opciones', + rows: opts.options.map(o => ({ id: o.id, title: o.title, ...(o.description ? { description: o.description } : {}) })), + }], + }, + }; + } + return { + type: 'button', + body: { text: bodyText }, + action: { + buttons: opts.options.slice(0, 3).map(o => ({ type: 'reply', reply: { id: o.id, title: o.title } })), + }, + }; +} + +// ── G-C: ruteo real de respuestas seq_* (openers v2) ───────────────────────── +// Un botón/fila `seq_:` del catálogo persona-first SIEMPRE +// recibe un siguiente paso real: paso 2 definido en el catálogo (`step2`), +// manejo semántico compartido (alek_directo / ahora_no / horarios) o acuse +// específico con eco de la elección + aviso al owner con la nextAction de la +// secuencia. Devuelve true si lo manejó; false SOLO para ids seq_* que no están +// en el catálogo (esos sí caen al fallback global, porque son inválidos). +async function handleIa360SequenceReply({ record, replyId, contact = null }) { + const m = /^seq_([a-z0-9_]+):([a-z0-9_]+)$/.exec(String(replyId || '').trim().toLowerCase()); + if (!m) return false; + const sequenceId = m[1]; + const optionKey = m[2]; + const found = findIa360SequenceFlow(sequenceId); + if (!found) return false; + const { sequence } = found; + const option = (sequence.openerOptions?.options || []) + .find(o => String(o.id).toLowerCase() === `seq_${sequenceId}:${optionKey}`); + if (!option) return false; + try { + const ctx = contact || await loadIa360ContactContext(record).catch(() => null); + const cf = ctx?.custom_fields || {}; + const contactName = ctx?.name || record.contact_name || record.contact_number; + const safeName = sanitizeIa360IntroName(contactName) || record.contact_number; + const nowIso = new Date().toISOString(); + + // Guard de estado (paridad con el router 100M): si la conversación ya avanzó + // a agenda/reunión/handoff humano, un botón seq_* de un opener viejo NO mueve + // el deal hacia atrás; responde continuidad y el owner se entera del tap. + const guard = await ia360HundredMAdvancedGuard(record); + if (guard.advanced) { + await enqueueIa360Text({ record, label: `ia360_seq_continuity_${sequenceId}`, body: guard.body }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_stale_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) tocó "${option.title}" de un opener viejo ("${sequence.label}"), pero su proceso ya va más adelante. No moví nada; le respondí con continuidad.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] stale notify:', e.message)); + return true; + } + + // Dedupe de doble tap del contacto: misma secuencia+opción ya registrada → + // continuidad corta, sin re-registro ni avisos duplicados al owner. + const prev = cf.ia360_seq_last_response || null; + if (prev && prev.sequence === sequenceId && prev.option === optionKey) { + await enqueueIa360Text({ + record, + label: `ia360_seq_dup_${sequenceId}`, + body: `Ya tengo registrada tu respuesta "${option.title}" y Alek ya tiene el contexto. Quedo al pendiente; cualquier cosa me escribes por aquí.`, + }); + return true; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-seq-respuesta', `seq-${sequenceId}`], + customFields: { + ia360_seq_last_response: { sequence: sequenceId, option: optionKey, title: option.title, at: nowIso }, + ia360_ultima_respuesta: option.title, + ultimo_cta_enviado: `ia360_seq_reply_${sequenceId}_${optionKey}`, + }, + }).catch(e => console.error('[ia360-seq] merge state:', e.message)); + + const notifyOwner = (detalle) => sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_reply_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) respondió "${option.title}" al opener "${sequence.label}". ${detalle}`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] notify owner:', e.message)); + + // 1) Salida directa con Alek. + if (optionKey === 'alek_directo') { + await enqueueIa360Text({ + record, + label: `ia360_seq_alek_directo_${sequenceId}`, + body: 'Perfecto, le aviso a Alek ahora mismo para que te escriba directo. Gracias por responder.', + }); + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió hablar directo con Alek.`, + }).catch(e => console.error('[ia360-seq] deal alek_directo:', e.message)); + await notifyOwner('Pidió que le escribas TÚ directo. Deal en "Requiere Alek".'); + return true; + } + + // 2) Cierre suave → nutrición. + if (optionKey === 'ahora_no') { + await enqueueIa360Text({ + record, + label: `ia360_seq_ahora_no_${sequenceId}`, + body: 'De acuerdo, no te insisto. Si más adelante quieres retomarlo, me escribes por aquí y seguimos donde lo dejamos.', + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: {}, + }).catch(e => console.error('[ia360-seq] tag nutricion:', e.message)); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: ahora no. Pasa a nutrición suave.`, + }).catch(e => console.error('[ia360-seq] deal ahora_no:', e.message)); + await notifyOwner('Respondió que ahora no; queda en nutrición suave.'); + return true; + } + + // 3) Agenda con permiso (referido_permiso_agenda:horarios). + if (optionKey === 'horarios') { + await enqueueIa360Interactive({ + record, + label: `ia360_seq_horarios_${sequenceId}`, + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + body: { text: 'Perfecto. ¿Qué ventana te acomoda mejor para la llamada con Alek?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió horarios. Deal a "Agenda en proceso".`, + }).catch(e => console.error('[ia360-seq] deal horarios:', e.message)); + await notifyOwner('Pidió horarios para una llamada contigo. Deal en "Agenda en proceso".'); + return true; + } + + // 4) Paso 2 definido en el catálogo. + const step2 = sequence.step2 && sequence.step2[optionKey]; + if (step2) { + await enqueueIa360Text({ + record, + label: `ia360_seq_step2_${sequenceId}_${optionKey}`, + body: step2, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Paso 2 de la secuencia enviado.`, + }).catch(e => console.error('[ia360-seq] deal step2:', e.message)); + await notifyOwner(`Le envié el paso 2 de la secuencia. Next action sugerida: ${sequence.nextAction}`); + return true; + } + + // 5) Sin paso 2 en el catálogo (temas de lista): acuse específico con eco de + // la elección + aviso al owner con la respuesta y la next action sugerida. + await enqueueIa360Text({ + record, + label: `ia360_seq_ack_${sequenceId}_${optionKey}`, + body: `Gracias, registré tu respuesta: "${option.title}". Le paso este contexto a Alek para que el siguiente paso vaya directo a eso, sin rodeos. Te escribe él con una propuesta concreta.`, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Acuse enviado; siguiente paso con Alek.`, + }).catch(e => console.error('[ia360-seq] deal ack:', e.message)); + await notifyOwner(`Next action sugerida: ${sequence.nextAction}`); + return true; + } catch (err) { + console.error('[ia360-seq] reply error:', err.message); + // Nunca mudo: acuse mínimo aunque el registro haya fallado. + await enqueueIa360Text({ + record, + label: 'ia360_seq_ack_error', + body: 'Recibí tu respuesta y ya se la pasé a Alek. Te escribe él en corto.', + }).catch(() => {}); + return true; + } +} + +// ── G-C: CTAs únicos — alias de botones de template (quick replies de texto) ── +// Los templates fríos (p. ej. ia360_referido_apertura = template 41, +// ia360_aliado_mapa_colaboracion = template 43) llegan con button.payload = +// TEXTO del botón, no un id estructurado, por lo que "Sí, cuéntame" era ambiguo +// entre Revenue OS y Referidos. Revenue OS se resuelve ANTES en el dispatch +// (handleRevenueOsButton, gateado por ia360_revenue_state); si no era suyo, este +// alias traduce el texto al id seq_* ÚNICO de la secuencia persona-first cuyo +// opener realmente se le envió al contacto (pf.sequence_candidate.id + pf.send). +const IA360_SEQ_ALIAS_NEGATIVE = new Set(['ahora no', 'por ahora no', 'no por ahora']); +const IA360_SEQ_ALIAS_HANDOFF = new Set(['que me escriba alek', 'hablar con alek']); +// Solo frases genuinamente afirmativas. Los títulos exactos del catálogo +// ("Proponme horarios", "Te respondo aquí", "Hazme una pregunta", etc.) se +// resuelven ANTES por match exacto de título (paso 1 del resolver), no por +// semántica: ponerlos aquí fabricaría elecciones equivocadas. Estos sets solo +// aplican cuando el texto del botón NO coincide con ningún título del opener. +const IA360_SEQ_ALIAS_AFFIRMATIVE = new Set([ + 'sí, cuéntame', 'si, cuéntame', 'sí, cuentame', 'si, cuentame', + 'sí, cuéntame más', 'si, cuentame mas', + 'sí, pregúntame', 'si, preguntame', + 'sí, mándalo', 'si, mandalo', + 'sí, compártelo', 'si, compartelo', + 'sí, a ver', 'si, a ver', + 'sí, te cuento', 'si, te cuento', + 'sí, hay un tema', 'si, hay un tema', + 'me interesa', 'sí, me interesa', 'si, me interesa', +]); + +function resolveIa360TemplateButtonAlias({ replyId, contact }) { + const key = String(replyId || '').trim().toLowerCase(); + if (!key || key.startsWith('seq_')) return null; + const pf = contact?.custom_fields?.ia360_persona_first; + const seqId = pf?.sequence_candidate?.id; + if (!seqId || !pf?.send?.sent_at) return null; // solo si su opener realmente salió + const found = findIa360SequenceFlow(seqId); + if (!found) return null; + const opts = found.sequence.openerOptions?.options || []; + // 1) Match exacto por título visible del botón — ANTES del gate semántico: + // los botones legítimos de los templates fríos ("Hazme una pregunta", + // "Te respondo aquí", "Todo va bien", "Mándame el mapa", "Proponme + // horarios"...) rutean por su propio título aunque no estén en los sets. + const byTitle = opts.find(o => String(o.title).trim().toLowerCase() === key); + if (byTitle) return String(byTitle.id).toLowerCase(); + // 2) Gate semántico, solo si no hubo match por título. + const isNeg = IA360_SEQ_ALIAS_NEGATIVE.has(key); + const isHand = IA360_SEQ_ALIAS_HANDOFF.has(key); + const isAff = IA360_SEQ_ALIAS_AFFIRMATIVE.has(key); + if (!isNeg && !isHand && !isAff) return null; + // 3) Por semántica: negativo → ahora_no; handoff → alek_directo; afirmativo → + // la primera opción que no sea ninguna de las dos (el camino afirmativo). + const bySuffix = (suffix) => opts.find(o => String(o.id).toLowerCase().endsWith(`:${suffix}`)); + if (isNeg) { const o = bySuffix('ahora_no'); return o ? String(o.id).toLowerCase() : null; } + if (isHand) { const o = bySuffix('alek_directo'); return o ? String(o.id).toLowerCase() : null; } + // Afirmativo SOLO cuando es inequívoco: la secuencia declara su opción de + // template (templateAliasOption) o existe exactamente UNA opción no terminal. + // Con varias opciones posibles (listas de temas) NO se fabrica una elección: + // se devuelve null y el fallback global acusa recibo y avisa al owner. + if (found.sequence.templateAliasOption) { + const o = bySuffix(found.sequence.templateAliasOption); + if (o) return String(o.id).toLowerCase(); + } + const nonTerminal = opts.filter(o => { + const id = String(o.id).toLowerCase(); + return !id.endsWith(':ahora_no') && !id.endsWith(':alek_directo'); + }); + return nonTerminal.length === 1 ? String(nonTerminal[0].id).toLowerCase() : null; +} + +// ── G-C: anti-loop del router 100M ─────────────────────────────────────────── +// Nodos que en las pruebas reales generaron ciclos (doc 2026-06-10, chat_history +// 1068-1079 y 1135-1142): exploración, mecanismos, mapa y ejemplo. Una visita +// repetida ya no reenvía el bloque completo: responde una versión condensada con +// salidas terminales (agendar / llamada / más adelante). +const IA360_100M_LOOP_PRONE = new Set([ + 'explorando', + 'mecanismo-whatsapp-crm', + 'mecanismo-erp-bi', + 'mecanismo-agentic-followup', + 'mapa-30-60-90-solicitado', + 'ejemplo-solicitado', +]); +// Etapas donde la conversación ya avanzó a agenda/handoff humano: un botón 100M +// de un mensaje viejo NO debe reabrir la rama (guard de estado/versión). +const IA360_100M_ADVANCED_STAGES = new Set(['Agenda en proceso', 'Reunión agendada', 'Requiere Alek']); + +async function ia360HundredMAdvancedGuard(record) { + const out = { advanced: false, body: '', visited: {}, visitedOk: false }; + try { + const contact = await loadIa360ContactContext(record).catch(() => null); + const cf = contact?.custom_fields || {}; + if (contact) { + out.visited = (cf.ia360_100m_visited && typeof cf.ia360_100m_visited === 'object') ? cf.ia360_100m_visited : {}; + out.visitedOk = true; // lectura confiable: se puede escribir sin pisar el mapa + } + // Solo reuniones FUTURAS cuentan como "en curso": el cache crudo ia360_bookings + // conserva citas pasadas y atraparía al contacto para siempre. + const bookings = await loadIa360BookingsForList(record.contact_number).catch(() => []); + const hasBooking = Array.isArray(bookings) && bookings.length > 0; + let stageName = ''; + const deal = await getActiveNonTerminalIa360Deal(record).catch(() => null); + if (deal) stageName = deal.stage_name || ''; + if (hasBooking || IA360_100M_ADVANCED_STAGES.has(stageName)) { + out.advanced = true; + out.body = (hasBooking || stageName === 'Reunión agendada') + ? 'Vi tu respuesta, pero tu proceso ya va más adelante: tienes una reunión en curso con Alek. Sigo con eso para no regresarte al inicio. Si quieres mover la reunión o retomar otro tema, dímelo por aquí.' + : 'Vi tu respuesta a un mensaje anterior, pero tu proceso ya va más adelante: estamos en la parte de agenda con Alek. Sigo con eso para no darte vueltas; si quieres retomar otro tema, dímelo por aquí y lo vemos.'; + } + } catch (err) { + console.error('[ia360-100m] advanced guard:', err.message); + } + return out; +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + // Openers v2: saludo con primer nombre (D9) + quién hizo la introducción (D6). + const draftName = ia360FirstNameFrom(name); + const quienIntro = String(customFields.quien_intro || '').trim() || null; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name: draftName, quienIntro }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + ...(sequence.openerOptions && Array.isArray(sequence.openerOptions.options) + ? ['', `Opciones del mensaje (${sequence.openerOptions.kind === 'list' ? 'lista' : 'botones'}): ${sequence.openerOptions.options.map(o => o.title).join(' | ')}`] + : []), + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +// G-D: pipelines donde un deal vivo habilita la jugada de expansión (D7). +const IA360_EXPANSION_PIPELINES = ['IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión']; + +// G-COLD: status real en Meta de los templates fríos, en UNA sola consulta. +// Si un nombre tiene varias filas gana APPROVED si alguna lo está (mismo +// criterio que enqueueIa360Template); si no, la más reciente por updated_at. +// En error de DB devuelve NULL (no {}): un {} significaría "template +// inexistente" y daría diagnóstico falso. Cada call site decide: la UX sale +// sin marcas (fail-open) y el envío se bloquea con aviso honesto (fail-closed). +async function loadIa360ColdTemplateStatuses(names) { + const list = [...new Set((names || []).filter(Boolean).map(String))]; + if (!list.length) return {}; + try { + const { rows } = await pool.query( + `SELECT name, status + FROM coexistence.message_templates + WHERE name = ANY($1) + ORDER BY updated_at DESC NULLS LAST, id DESC`, + [list] + ); + const out = {}; + for (const row of rows) { + if (String(out[row.name] || '').toUpperCase() === 'APPROVED') continue; + if (String(row.status || '').toUpperCase() === 'APPROVED') { out[row.name] = row.status; continue; } + if (!(row.name in out)) out[row.name] = row.status; // primera fila = más reciente + } + return out; + } catch (e) { + console.error('[ia360-cold] template statuses lookup:', e.message); + return null; + } +} + +// G-COLD: traduce el status de Meta a disponibilidad para envío en frío. +function ia360ColdAvailability(status) { + const s = String(status || '').toUpperCase(); + if (s === 'APPROVED') return { sendable: true, label: '✓ lista para frío' }; + if (['PENDING', 'SUBMITTED', 'IN_REVIEW'].includes(s)) return { sendable: false, label: 'template en revisión Meta' }; + if (s === 'REJECTED') return { sendable: false, label: 'template rechazado por Meta' }; + return { sendable: false, label: 'sin template frío' }; +} + +// G-COLD: ¿el contacto está fuera de la ventana de servicio de 24h? Mismo +// umbral 23.5h que approve-send. Error o cuenta sin resolver → {known:false} +// (fail-open: la UX del selector nunca debe romperse por esta verificación). +async function ia360OutsideWindow24h({ record, targetContact }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) return { known: false, outside: false }; + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + return { known: true, outside: !(secs != null && secs < 23.5 * 3600) }; + } catch (e) { + console.error('[ia360-cold] window check:', e.message); + return { known: false, outside: false }; + } +} + +// G-D: señales reales del contacto para el ranker del selector de secuencias. +// Cada consulta tiene su propio try/catch (fail-open): si la DB falla, esa señal +// queda en null y el selector sale con el orden default — nunca mudo. +// OJO: ia360_memory_* tiene doble keying en contact_wa_number (a veces la línea +// del bot, a veces el número del contacto); la llave confiable es contact_number. +async function gatherIa360ContactSignals({ waNumber, contactNumber }) { + const signals = { liveDeal: null, quienIntro: null, lastFact: null, lastEvent: null, lastIncomingAt: null }; + try { + const { rows } = await pool.query( + `SELECT d.title, p.name AS pipeline_name, s.name AS stage_name + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.contact_wa_number = $1 AND d.contact_number = $2 AND d.status = 'open' + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [waNumber, contactNumber] + ); + if (rows.length) signals.liveDeal = { title: rows[0].title, pipelineName: rows[0].pipeline_name, stageName: rows[0].stage_name }; + } catch (e) { console.error('[ia360-rank] deal lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro, custom_fields->>'referido_por' AS referido_por + FROM coexistence.contacts + WHERE wa_number = $1 AND contact_number = $2 + LIMIT 1`, + [waNumber, contactNumber] + ); + const quienIntro = String(rows[0]?.quien_intro || '').trim(); + if (quienIntro) { + signals.quienIntro = quienIntro; + } else { + // referido_por guarda el NÚMERO de quien compartió el vCard. Solo cuenta + // como introductor si NO es el owner, ni el bot, ni el propio contacto; + // y solo con un nombre presentable (no citamos números pelones). + const referidoPor = normalizePhone(String(rows[0]?.referido_por || '').trim()); + if (referidoPor && referidoPor !== IA360_OWNER_NUMBER && referidoPor !== normalizePhone(waNumber) && referidoPor !== normalizePhone(contactNumber)) { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number = $1 AND contact_number = $2 LIMIT 1`, + [waNumber, referidoPor] + ); + const introName = String(introRows[0]?.name || introRows[0]?.profile_name || '').trim(); + if (introName) signals.quienIntro = introName; + } + } + } catch (e) { console.error('[ia360-rank] quien_intro lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT COALESCE(recurring_pain, preference, objection, role) AS texto, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + AND COALESCE(recurring_pain, preference, objection, role) IS NOT NULL + ORDER BY last_seen_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastFact = { text: rows[0].texto, lastSeenAt: rows[0].last_seen_at }; + } catch (e) { console.error('[ia360-rank] facts lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT summary, created_at + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastEvent = { summary: rows[0].summary, createdAt: rows[0].created_at }; + } catch (e) { console.error('[ia360-rank] events lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT MAX(created_at) AS last_in + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 AND direction = 'incoming'`, + [waNumber, contactNumber] + ); + if (rows[0]?.last_in) signals.lastIncomingAt = rows[0].last_in; + } catch (e) { console.error('[ia360-rank] chat_history lookup:', e.message); } + return signals; +} + +// G-D: ranker rule-based (SIN LLM). Solo REORDENA las secuencias de la persona +// elegida; cada razón cita una señal que existe en la base. Sin señales que +// matcheen secuencias de esta persona → orden de catálogo y cero razones +// inventadas (honestidad del ranker). +function rankIa360Sequences({ flow, signals }) { + const scores = new Map(); + const reasons = new Map(); + const bump = (id, pts, reason) => { + scores.set(id, (scores.get(id) || 0) + pts); + if (reason && !reasons.has(id)) reasons.set(id, reason); + }; + const s = signals || {}; + if (s.liveDeal) { + const dealReason = `Deal vivo «${s.liveDeal.title}» en ${s.liveDeal.pipelineName}`; + if (IA360_EXPANSION_PIPELINES.includes(s.liveDeal.pipelineName)) { + bump('cliente_expansion', 35, dealReason); + bump('cliente_readout', 20, dealReason); + bump('cliente_soporte', 10, dealReason); + } else { + bump('cliente_readout', 30, dealReason); + bump('cliente_soporte', 20, dealReason); + } + } + if (s.quienIntro) { + const introReason = `Te lo presentó ${s.quienIntro}`; + bump('referido_contexto', 30, introReason); + bump('referido_permiso_agenda', 15, introReason); + bump('referido_oneliner', 10, introReason); + } + const memorySignal = s.lastEvent || s.lastFact; + if (memorySignal) { + // 40 y no más: "Sugerida: Memoria registrada: " + frag debe caber en los + // 72 chars de la description de Meta sin perder el final de la razón. + const frag = compactForWhatsApp(s.lastEvent ? s.lastEvent.summary : s.lastFact.text, 40); + const memReason = `Memoria registrada: ${frag}`; + bump('beta_memoria', 15, memReason); + bump('cliente_readout', 10, memReason); + } + const ordered = (flow.sequences || []) + .map((seq, idx) => ({ seq, idx, score: scores.get(seq.id) || 0 })) + .sort((a, b) => (b.score - a.score) || (a.idx - b.idx)); + const ranked = ordered.length > 0 && ordered[0].score > 0; + return { + ordered: ordered.map(o => o.seq), + suggestedId: ranked ? ordered[0].seq.id : null, + reasonFor: (id) => reasons.get(id) || null, + ranked, + }; +} + +// G-D: resumen de 2 líneas del contacto para el cuerpo de la tarjeta. Solo +// afirma lo que existe; sin señales devuelve una sola línea honesta. +function buildIa360ContactSummaryLines(signals) { + const s = signals || {}; + const fmtDate = (d) => { + try { return new Date(d).toISOString().slice(0, 10); } catch { return ''; } + }; + if (!s.liveDeal && !s.quienIntro && !s.lastFact && !s.lastEvent) { + return ['Aún no tengo señales registradas de este contacto (sin deal, sin memoria, sin introductor).']; + } + // Tope de 180: título/pipeline/etapa vienen de la base sin límite y el body + // del interactive de Meta admite 1024 como máximo — si se excede, la tarjeta + // se vuelve muda. Acotado aquí, el body completo queda siempre < 1024. + const line1 = s.liveDeal + ? compactForWhatsApp(`Deal vivo: «${s.liveDeal.title}» — ${s.liveDeal.pipelineName}${s.liveDeal.stageName ? ` / ${s.liveDeal.stageName}` : ''}.`, 180) + : 'Sin deal vivo registrado.'; + let line2; + if (s.lastEvent) { + const fecha = fmtDate(s.lastEvent.createdAt); + line2 = `Último evento${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastEvent.summary, 120)}`; + } else if (s.lastFact) { + const fecha = fmtDate(s.lastFact.lastSeenAt); + line2 = `Memoria${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastFact.text, 120)}`; + } else if (s.quienIntro) { + line2 = `Lo presentó: ${s.quienIntro}.`; + } else if (s.lastIncomingAt) { + line2 = `Última interacción: ${fmtDate(s.lastIncomingAt)}.`; + } else { + line2 = 'Sin memoria registrada todavía.'; + } + return [line1, line2]; +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + // G-D: ranker rule-based sobre señales reales — la sugerida primero con el + // porqué en su descripción; sin señales → orden de catálogo sin razones. + const signals = await gatherIa360ContactSignals({ waNumber: record.wa_number, contactNumber: targetContact }); + const ranking = rankIa360Sequences({ flow, signals }); + const summaryLines = buildIa360ContactSummaryLines(signals); + // G-COLD: si el contacto está fuera de la ventana de 24h, cada fila antepone + // la disponibilidad real del template frío (status en coexistence.message_templates). + // Fail-open con try/catch: si algo falla, el selector sale como hoy y se loggea. + let coldInfo = null; + try { + const win = await ia360OutsideWindow24h({ record, targetContact }); + if (win.known && win.outside) { + const statuses = await loadIa360ColdTemplateStatuses( + (flow.sequences || []).map(s => s.metaTemplateName).filter(Boolean) + ); + // null = falló el lookup (≠ inexistente): selector sin marcas, como hoy. + if (statuses === null) { + console.error('[ia360-cold] selector: lookup de statuses falló; selector sin marcas frías'); + } else { + coldInfo = { outsideWindow: true, statuses }; + } + } + } catch (e) { console.error('[ia360-cold] selector availability:', e.message); } + const bodyText = [ + `Alek, ${name} quedó como ${flow.personaContext}.`, + ...summaryLines, + ...(coldInfo ? ['Fuera de ventana de 24h: solo las secuencias marcadas «lista para frío» pueden salir hoy (como template de Meta).'] : []), + 'Elige una secuencia. Sigo en dry-run: no enviaré nada al contacto.', + ].join('\n'); + const suggestedReason = ranking.suggestedId ? ranking.reasonFor(ranking.suggestedId) : null; + // G-COLD: las filas se calculan una sola vez — se renderizan en la tarjeta y + // se persisten tal cual en ia360_selector_ranking.rows (auditoría 1:1). + const selectorRows = ranking.ordered.map(seq => { + const reason = ranking.suggestedId === seq.id ? ranking.reasonFor(seq.id) : null; + let description; + if (coldInfo) { + const avail = ia360ColdAvailability(seq.metaTemplateName ? coldInfo.statuses[seq.metaTemplateName] : undefined); + description = compactForWhatsApp(`${avail.label} · ${reason ? `Sugerida: ${reason}` : seq.goal}`, 72); + } else { + description = compactForWhatsApp(reason ? `Sugerida: ${reason}` : seq.goal, 72); + } + return { + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description, + }; + }); + // G-D: el ranking queda auditable en custom_fields (orden, sugerida, razón, + // resumen) — best-effort, no bloquea el envío de la tarjeta. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + customFields: { + ia360_selector_ranking: { + at: new Date().toISOString(), + persona: flowKey, + ranked: ranking.ranked, + suggested: ranking.suggestedId, + reason: suggestedReason, + order: ranking.ordered.map(seq => seq.id), + summary: summaryLines, + // G-COLD: filas tal como se renderizaron en la tarjeta. + rows: selectorRows, + ...(coldInfo ? { + cold: { + outside_window: true, + availability: Object.fromEntries( + (flow.sequences || []) + .filter(seq => seq.metaTemplateName) + .map(seq => { + const status = coldInfo.statuses[seq.metaTemplateName] || null; + return [seq.id, { template: seq.metaTemplateName, status, label: ia360ColdAvailability(status).label }]; + }) + ), + }, + } : {}), + }, + }, + }).catch(e => console.error('[ia360-rank] persist ranking:', e.message)); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: ranking.ranked + ? `IA360: secuencias ${name} — sugerida: ${ranking.suggestedId} (${suggestedReason})` + : `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { text: bodyText }, + footer: { + text: ranking.ranked + ? 'Sugerida primero por señales; aprobación antes de envío' + : 'Persona antes de secuencia; aprobación antes de envío', + }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + // G-COLD: mismas filas que quedaron persistidas en el ranking. + rows: selectorRows, + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + let readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + // G-COLD: aviso pre-aprobación. Si el contacto está fuera de la ventana de 24h, + // el owner debe saber ANTES de aprobar si el opener saldrá como template de + // Meta (mismo copy, con botones) o si no puede salir nada todavía. + // Fail-open: si la verificación falla, el readout sale como hoy. + let coldBlocked = false; + let coldNotice = null; + try { + const win = await ia360OutsideWindow24h({ record, targetContact }); + if (win.known && win.outside) { + const tplName = sequence.metaTemplateName || null; + if (!tplName) { + // Defensivo: hoy las 24 secuencias tienen template mapeado, pero si una + // nueva llega sin él, el aviso no debe imprimir «null». + coldBlocked = true; + readout += `\n\nAVISO: ${name} está fuera de la ventana de 24h y esta secuencia no tiene template frío mapeado. Si apruebas ahora NO puede salir nada.`; + coldNotice = 'Fuera de ventana de 24h y sin template frío mapeado: hoy no puede salir nada.'; + } else { + const statuses = await loadIa360ColdTemplateStatuses([tplName]); + if (statuses === null) { + // null = falló el lookup (≠ inexistente): sin aviso ni coldBlocked, + // comportamiento previo; el cinturón del approve-send re-verifica. + console.error('[ia360-cold] readout: lookup de statuses falló; readout sin aviso frío'); + } else { + const avail = ia360ColdAvailability(statuses[tplName]); + if (avail.sendable) { + readout += `\n\nFuera de ventana de 24h: si apruebas, el opener saldrá como template aprobado de Meta «${tplName}» (mismo copy, con sus botones), no como texto libre.`; + coldNotice = `Fuera de ventana de 24h: el opener saldrá como template «${tplName}».`; + } else { + coldBlocked = true; + readout += `\n\nAVISO: ${name} está fuera de la ventana de 24h y el template «${tplName}» de esta secuencia aún no está aprobado por Meta (${avail.label}). Si apruebas ahora NO puede salir nada. Opciones: espera la aprobación de Meta, elige una secuencia marcada «lista para frío» o toma el contacto manual.`; + coldNotice = `Fuera de ventana de 24h y sin template aprobado (${avail.label}): hoy no puede salir nada.`; + } + } + } + } + } catch (e) { console.error('[ia360-cold] readout availability:', e.message); } + // G-COLD: best-effort, solo auditoría/QA — el cinturón del approve-send + // re-consulta el status en vivo; nadie lee este campo en runtime. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + customFields: { ia360_approve_card_cold_blocked: coldBlocked }, + }).catch(e => console.error('[ia360-cold] persist cold_blocked:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence, coldBlocked, coldNotice }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence, coldBlocked = false, coldNotice = null }) { + // G-COLD: con coldNotice el owner ve en la tarjeta cómo saldría el opener (o + // por qué no puede salir); con coldBlocked se RETIRA la fila "Aprobar y + // enviar" — el owner jamás debe poder aprobar algo imposible. + let bodyText = `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`; + if (coldNotice) bodyText += `\n${coldNotice}`; + if (bodyText.length > 1024) bodyText = compactForWhatsApp(bodyText, 1024); + const rows = [ + ...(coldBlocked ? [] : [{ id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }]), + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ]; + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { text: bodyText }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows, + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // G-C: dedupe de doble tap. Si esta misma secuencia ya fue aprobada y su envío + // ya salió SIN fallar, un segundo tap de la tarjeta NO debe generar otro egress. + // Un envío fallido NO bloquea: el owner puede reintentar con la misma tarjeta. + if (pf.approval?.status === 'approved' && pf.send?.sent_at && String(pf.send.send_status || '').toLowerCase() !== 'failed' && !pf.send.error) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_dup', + body: `Ese opener ("${sequence.label}") ya se había enviado a ${name} (${pf.send.sent_at}). Detecté un doble tap y no envié nada nuevo.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // GUARDIA cliente_expansion (D7): la secuencia presupone un proyecto andando. + // Solo dispara si el contacto tiene un deal vivo (status='open') en P2 (IA360 + // WhatsApp Revenue Pipeline) o P7 (Champions). Sin deal vivo → bloquear con aviso. + // G-C: con try/catch — si la consulta falla, el owner se entera (nunca mudo) y + // NO se envía nada (fail-closed). + if (sequence.requiresLiveDeal) { + let liveRows; + try { + ({ rows: liveRows } = await pool.query( + `SELECT 1 + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') + AND d.contact_wa_number = $1 + AND d.contact_number = $2 + AND d.status = 'open' + LIMIT 1`, + [record.wa_number, targetContact] + )); + } catch (liveErr) { + console.error('[ia360-approve] live deal check failed:', liveErr.message); + return deny('live_deal_check_failed', `No pude verificar si ${name} tiene un proyecto activo (error de base de datos). Por seguridad no envié nada; reintenta en un momento.`); + } + if (!liveRows.length) { + return deny('no_live_deal', `${name} no tiene un proyecto activo (deal vivo en P2/P7). La secuencia ${sequence.id} solo aplica a clientes con proyecto en curso; elige otra secuencia. No envié nada.`); + } + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template). + // G-COLD: las 24 secuencias persona-first ya tienen metaTemplateName mapeado; + // el deny outside_window_no_template queda solo como red de seguridad. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + // Openers v2: dentro de ventana el opener sale como interactive (botones/lista) + // con el copy aprobado en el readout; secuencias sin openerOptions siguen en texto. + const openerInteractive = buildIa360OpenerInteractive({ sequence, bodyText: pf.sequence_candidate.draft }); + let sent; + let handlerFor; + if (openerInteractive) { + sent = await enqueueIa360Interactive({ + record: targetRecord, + label: openerLabel, + messageBody: `IA360 opener: ${sequence.label}`, + interactive: openerInteractive, + dedupSuffix: `:opener:${targetContact}`, + }); + handlerFor = `${record.message_id}:opener:${targetContact}`; + } else { + sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + handlerFor = `${record.message_id}:direct:${targetContact}`; + } + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(handlerFor); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + // G-COLD: cinturón contra tarjetas viejas o races. La tarjeta sin la fila + // "Aprobar y enviar" protege la UX, pero un tap sobre una tarjeta emitida + // antes (o un cambio de status en Meta entre tarjeta y tap) llegaría hasta + // aquí: se consulta el status REAL del template antes de encolar nada. + const coldStatuses = await loadIa360ColdTemplateStatuses([sequence.metaTemplateName]); + if (coldStatuses === null) { + // null = falló la consulta (≠ template inexistente): fail-closed en el + // envío, pero con diagnóstico honesto para el owner. + return deny('cold_template_status_check_failed', `No pude verificar el status del template «${sequence.metaTemplateName}» en la base (error de consulta). Por seguridad no envié nada; reintenta en un momento.`); + } + const coldStatus = coldStatuses[sequence.metaTemplateName] || null; + if (String(coldStatus || '').toUpperCase() !== 'APPROVED') { + return deny('outside_window_template_not_approved', `${name} está fuera de la ventana de 24h y el template «${sequence.metaTemplateName}» de la secuencia ${sequence.id} aún no está aprobado por Meta (${coldStatus || 'inexistente'}). No envié nada.`); + } + // G-COLD: referido_contexto en frío necesita el {{2}} (quien_intro), igual + // que el draft caliente lo calcula buildIa360PersonaPayload. Sin el dato, + // el template saldría con un hueco — mejor avisar con honestidad. + // Sanitizado vía compactForWhatsApp (colapsa espacios/saltos y topa a 60): + // Meta rechaza parámetros con saltos de línea o 4+ espacios consecutivos. + let templateVars = null; + if (sequence.id === 'referido_contexto') { + const quienIntro = compactForWhatsApp(contact.custom_fields?.quien_intro || '', 60); + if (!quienIntro) { + return deny('cold_send_missing_quien_intro', `El template de ${sequence.id} necesita saber quién hizo la introducción y ${name} no tiene quien_intro registrado. Captura ese dato (o elige otra secuencia) y vuelve a intentar. No envié nada.`); + } + templateVars = { '2': quienIntro }; + } + // allowTextFallback:false — en frío un fallback a texto libre sería + // rechazado por Meta y reportaría éxito falso (ver enqueueIa360Template). + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName, vars: templateVars, allowTextFallback: false }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + // G-C: un opener nuevo abre un ciclo nuevo — la respuesta del ciclo anterior + // no debe activar el dedupe del router seq_* (el contacto debe poder volver + // a elegir la misma opción y recibir su paso 2). + ia360_seq_last_response: null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + // G-LIVE: el remitente tampoco debe quedar mudo cuando la tarjeta no se pudo leer. + if (!isIa360OwnerNumber(record.contact_number)) { + await enqueueIa360Text({ + record, + label: 'ia360_vcard_ack', + body: 'Recibí la tarjeta, pero no pude leer bien el número. Se la paso a Alek para revisarla y te confirmo por aquí.', + }).catch(e => console.error('[ia360-vcard] parse-fail ack error:', e.message)); + } + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + // G-LIVE (auditoría 06-11): un CONTACTO (no owner) que comparte una tarjeta + // merece acuse — antes solo se notificaba al owner y el remitente quedaba mudo. + if (!isIa360OwnerNumber(record.contact_number)) { + await enqueueIa360Text({ + record, + label: 'ia360_vcard_ack', + body: 'Recibí la tarjeta, gracias. La registro y le digo a Alek; cualquier siguiente paso te lo confirmo por aquí.', + }).catch(e => console.error('[ia360-vcard] contact ack error:', e.message)); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── Expediente del owner: "qué sabes de " ────────────────── +// Comando read-only del owner: arma un expediente con los facts y eventos de +// coexistence.ia360_memory_* para un contacto, resuelto por número o por +// nombre (tolerante a acentos y a typos simples tipo Emmanuel/Emanuel). +// Egress SOLO vía sendIa360DirectText; nunca escribe memoria y SIEMPRE +// responde algo (sin expediente / candidatos / error), nunca queda mudo. +const IA360_BOT_WA_NUMBER = '5213321594582'; // número del bot: jamás es contacto + +function parseIa360OwnerMemoryQuery(body) { + const text = String(body || '').trim(); + const m = text.match(/^¿?\s*(?:qu[eé]|qui[eé]n)\s+sabes\s+(?:de\s+la|de\s+el|del|de|sobre)\s+(.+?)\s*\?*$/i); + if (!m) return null; + const q = m[1].trim(); + return q || null; +} + +// Normaliza para comparar nombres: minúsculas, sin acentos y con letras +// repetidas colapsadas ("Emmanuel" y "Emanuel" → "emanuel"). +function ia360NormalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +async function resolveIa360MemoryTarget(query) { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + // Número directo: 10 dígitos MX → prefijo 521 (formato ForgeChat). + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const { rows } = await pool.query( + `SELECT DISTINCT contact_number, contact_name FROM ( + SELECT contact_number, contact_name + FROM coexistence.ia360_memory_events + WHERE contact_name IS NOT NULL AND contact_number IS NOT NULL + UNION ALL + SELECT contact_number, COALESCE(name, profile_name) AS contact_name + FROM coexistence.contacts + WHERE COALESCE(name, profile_name) IS NOT NULL AND contact_number IS NOT NULL + ) t + WHERE contact_number <> $1`, + [IA360_BOT_WA_NUMBER] + ); + const needle = ia360NormalizeNameForMatch(query); + if (!needle) return { kind: 'none', candidates: [] }; + const byNumber = new Map(); + for (const r of rows) { + if (!ia360NormalizeNameForMatch(r.contact_name).includes(needle)) continue; + if (!byNumber.has(r.contact_number)) byNumber.set(r.contact_number, r); + } + const candidates = [...byNumber.values()]; + if (!candidates.length) return { kind: 'none', candidates: [] }; + if (candidates.length > 1) return { kind: 'ambiguous', candidates }; + return { kind: 'name', candidates }; +} + +async function buildIa360ContactDossier(contactNumber) { + const num = normalizePhone(contactNumber); + const { rows: factRows } = await pool.query( + `SELECT project_name, persona, role, account_name, preference, objection, + recurring_pain, affected_process, missing_metric + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + ORDER BY last_seen_at DESC, id DESC`, + [num] + ); + const { rows: eventRows } = await pool.query( + `SELECT contact_name, area, signal_type, summary + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC, id DESC + LIMIT 12`, + [num] + ); + if (!factRows.length && !eventRows.length) return null; + + const name = eventRows.find(e => e.contact_name)?.contact_name || null; + const header = [`Expediente IA360: ${name || 'contacto'} (${num})`]; + const meta = []; + const accountRow = factRows.find(f => f.account_name); + const projectRow = factRows.find(f => f.project_name); + const personaRow = factRows.find(f => f.persona); + if (accountRow) meta.push(`Cuenta: ${accountRow.account_name}`); + if (projectRow) meta.push(`Proyecto: ${projectRow.project_name}`); + if (personaRow) meta.push(`Persona: ${personaRow.persona}`); + if (meta.length) header.push(meta.join(' · ')); + + // Los facts viven duplicados por el doble keying de contact_wa_number + // (monolito vs lookup v2): dedupe por contenido, no por fila. + const factLines = []; + const seenFacts = new Set(); + for (const f of factRows) { + for (const field of ['preference', 'objection', 'recurring_pain', 'affected_process', 'missing_metric']) { + const val = String(f[field] || '').trim(); + if (!val) continue; + const key = `${field}:${val}`; + if (seenFacts.has(key)) continue; + seenFacts.add(key); + factLines.push(`- ${val.length > 300 ? `${val.slice(0, 297)}...` : val}`); + } + } + const eventLines = []; + const seenEvents = new Set(); + for (const e of eventRows) { + const val = String(e.summary || '').trim(); + if (!val) continue; + const key = `${e.area}|${e.signal_type}|${val}`; + if (seenEvents.has(key)) continue; + seenEvents.add(key); + eventLines.push(`- [${e.area}/${e.signal_type}] ${val.length > 220 ? `${val.slice(0, 217)}...` : val}`); + } + + const lines = [...header, '']; + if (factLines.length) lines.push(`Facts (${factLines.length}):`, ...factLines, ''); + if (eventLines.length) lines.push(`Eventos recientes (${eventLines.length}):`, ...eventLines); + let body = lines.join('\n').trim(); + // Límite duro de WhatsApp: 4096 chars por texto. + if (body.length > 3900) body = `${body.slice(0, 3880)}\n... (recortado)`; + return body; +} + +async function handleIa360OwnerMemoryQuery({ record, query }) { + let body; + try { + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + body = `Sin expediente: no encontré facts ni eventos para "${query}". Revisa el nombre o mándame el número completo.`; + } else if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + body = `Encontré varios contactos que coinciden con "${query}". ¿De cuál quieres el expediente?\n${list}\n\nMándame "qué sabes de " para verlo.`; + } else { + const dossier = await buildIa360ContactDossier(target.candidates[0].contact_number); + body = dossier + || `Sin expediente: el contacto ${target.candidates[0].contact_number} no tiene facts ni eventos guardados todavía.`; + } + } catch (err) { + console.error('[ia360-expediente] dossier error:', err.message); + body = `No pude leer el expediente de "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); +} + +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu pregunta. Para responderte bien y no darte una respuesta incompleta, voy a revisar el contexto con Alek y te confirmo por aquí en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +// ── G-LIVE: invariante estructural no-silencio (watchdog por inbound) ──────────── +// La clase del incidente Andrés (38 min mudo): una rama "marca como manejado" sin +// egress real. La verdad no es ningún flag en memoria: es la fila outgoing en +// chat_history (insertPendingRow la escribe al encolar, así que es visible de +// inmediato). 75 s después del inbound de un cliente activo/beta (o con deal +// ganado), si no existe NINGÚN outgoing hacia ese contacto desde el inbound => +// holding + alerta al owner + failure registrado. Cubre toda rama presente o +// futura (texto, botones, audio, vCard) sin parchar rama por rama. El dedupe de +// resolveIa360Outbound (ia360_handler_for = message_id) lo hace idempotente. +const IA360_NO_SILENCE_WATCHDOG_MS = 75000; + +async function ia360HasOutgoingForInbound(record) { + // Escape de comodines LIKE: los wamid del harness pueden traer '_' o '%'. + const wamidLike = String(record.message_id || '~none~').replace(/[\\%_]/g, '\\$&'); + const { rows } = await pool.query( + `SELECT 1 + FROM coexistence.chat_history o + WHERE o.direction = 'outgoing' + AND o.contact_number = $1 + AND COALESCE(o.status, '') NOT IN ('failed', 'error') + AND (o.template_meta->>'ia360_handler_for' LIKE $2 || '%' + OR o.timestamp >= (SELECT i.timestamp FROM coexistence.chat_history i + WHERE i.message_id = $3 LIMIT 1)) + LIMIT 1`, + [record.contact_number, wamidLike, record.message_id || '~none~'] + ); + return rows.length > 0; +} + +async function ia360IsWatchedActiveContact(record) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) return true; + // Cliente con deal GANADO sin marca beta en contacts: también es cliente real + // activo que jamás debe quedar en silencio (hallazgo alta de la auditoría 06-11). + try { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.contact_wa_number = $1 AND d.contact_number = $2 + AND (s.stage_type = 'won' OR s.name = 'Ganado') + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows.length > 0; + } catch (e) { + console.error('[ia360-no-silence] won-deal check error:', e.message); + return false; + } +} + +async function ia360NoSilenceWatchdogCheck(record) { + if (!(await ia360IsWatchedActiveContact(record))) return; + if (await ia360HasOutgoingForInbound(record)) return; + console.error('[ia360-no-silence] INVARIANTE DISPARADO contact=%s message=%s type=%s', + maskIa360Number(record.contact_number), record.message_id, record.message_type); + await handleIa360BotFailure({ + record, + reason: `invariante no-silencio: sin fila outgoing en chat_history ${Math.round(IA360_NO_SILENCE_WATCHDOG_MS / 1000)}s después del inbound de cliente activo/beta`, + alreadyResponded: false, + }); +} + +function scheduleIa360NoSilenceWatchdog(record) { + try { + if (!record || record.direction !== 'incoming') return; + if (record.message_type === 'status' || record.message_type === 'reaction' || record.message_type === 'sticker') return; + if (!record.message_id) return; // sin wamid no hay forma confiable de verificar el egress + const n = normalizePhone(record.contact_number); + if (!n || n === IA360_OWNER_NUMBER) return; + // Texto pasivo ("gracias", "ok") no exige respuesta (diseño deliberado Gap#1). + if (record.message_type === 'text' && isIa360PassiveMessage(record.message_body)) return; + // Subset del record: no retener raw_payload (batches n8n) en memoria 75 s. + const slim = { + wa_number: record.wa_number, + contact_number: record.contact_number, + contact_name: record.contact_name || null, + message_id: record.message_id, + message_type: record.message_type, + message_body: record.message_body || null, + direction: record.direction, + }; + const t = setTimeout(() => { + ia360NoSilenceWatchdogCheck(slim).catch(e => + console.error('[ia360-no-silence] watchdog error:', e.message)); + }, IA360_NO_SILENCE_WATCHDOG_MS); + if (typeof t.unref === 'function') t.unref(); + } catch (e) { + console.error('[ia360-no-silence] schedule error:', e.message); + } +} + +// G-LIVE: un deploy/restart dentro de la ventana del watchdog perdería los timers en +// memoria — y el deploy es justo el momento de mayor riesgo de egress roto. Al boot, +// re-escanear los inbounds recientes (15 min) de no-owner y re-correr el check. +const ia360BootRescanTimer = setTimeout(async () => { + try { + const { rows } = await pool.query( + `SELECT message_id, wa_number, contact_number, message_type, message_body + FROM coexistence.chat_history + WHERE direction = 'incoming' + AND message_type NOT IN ('status', 'reaction', 'sticker') + AND timestamp > NOW() - INTERVAL '15 minutes'` + ); + let checked = 0; + for (const r of rows) { + if (!r.message_id) continue; + if (normalizePhone(r.contact_number) === IA360_OWNER_NUMBER) continue; + if (r.message_type === 'text' && isIa360PassiveMessage(r.message_body)) continue; + checked += 1; + await ia360NoSilenceWatchdogCheck({ ...r, direction: 'incoming' }) + .catch(e => console.error('[ia360-no-silence] boot rescan check:', e.message)); + } + if (checked) console.log('[ia360-no-silence] boot rescan: %d inbound(s) recientes revisados', checked); + } catch (e) { + console.error('[ia360-no-silence] boot rescan error:', e.message); + } +}, 90000); +if (typeof ia360BootRescanTimer.unref === 'function') ia360BootRescanTimer.unref(); + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + // G-LIVE (P0 producción): cliente activo/beta SIEMPRE recibe respuesta real. + // 1) Captura de memoria: aprendizaje en segundo plano REAL (fire-and-forget, + // no suma latencia al camino caliente) y NUNCA cuenta como respuesta (la + // clase del bug de Andrés: dry-run devolvía true y el padre creía que ya + // había respondido). + handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext, captureOnly: true }) + .catch(e => console.error('[ia360-memory] capture error:', e.message)); + + // 2) Datos vivos del portal del cliente: WhatsApp no tiene acceso => handoff + // explícito y honesto. NUNCA inventar listas ni cifras. + if (isIa360PortalLiveDataQuestion(record.message_body)) { + const sentHandoff = await enqueueIa360Text({ + record, + label: 'ia360_cliente_activo_portal_handoff', + body: 'Ese dato vive en el portal y prefiero confirmarte la cifra exacta antes que darte una aproximación. Lo reviso con Alek y te confirmo por aquí en breve.', + }); + if (sentHandoff) responded = true; + await handleIa360BotFailure({ + record, + reason: 'handoff: pregunta de datos vivos del portal del cliente (sin acceso desde WhatsApp)', + alreadyResponded: sentHandoff === true, + }).catch(e => console.error('[ia360-failure] portal handoff alert error:', e.message)); + return; + } + + // 3) Respuesta REAL del agente IA (memoria incluida vía memory-lookup del + // workflow). UNA sola respuesta; prohibidas cadenas de "corrijo". + // [qa-force-agent-down] simula agente caído SOLO para números QA (E2E). + const qaForceDown = IA360_QA_NUMBER_RE.test(String(record.contact_number || '')) + && /\[qa-force-agent-down\]/i.test(String(record.message_body || '')); + const agentBeta = qaForceDown + ? null + : await callIa360Agent({ + record, + stageName: deal.stage_name, + roleHint: buildIa360ClienteActivoBetaRoleHint(contactContext), + }); + const betaReply = agentBeta && typeof agentBeta.reply === 'string' ? agentBeta.reply.trim() : ''; + if (betaReply) { + const sentBeta = await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_agent_reply', body: betaReply }); + if (sentBeta) { + // Gestión de citas detectada por el agente: la acción de calendario del + // embudo no aplica al pseudo-deal beta, así que el loop lo cierra Alek. + const betaAction = String(agentBeta.action || agentBeta.intent || ''); + if (['list_bookings', 'cancel', 'reschedule', 'offer_slots', 'book'].includes(betaAction)) { + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_beta_meeting_intent', + body: `IA360: el cliente activo/beta ${record.contact_name || record.contact_number} pidió gestión de cita (${betaAction}): "${String(record.message_body || '').slice(0, 140)}". Ya le respondí, pero la acción de calendario requiere tu confirmación.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-agent] beta meeting alert error:', e.message)); + } + responded = true; + return; + } + } + + // 4) Holding SOLO si el agente falló o no devolvió respuesta utilizable: + // holding al contacto + alerta al owner + failure registrado. + await handleIa360BotFailure({ + record, + reason: 'cliente activo/beta: agente IA sin respuesta utilizable (caído, timeout o reply vacío)', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] cliente-activo fallback error:', e.message)); + responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // ── G-C: ruteo de respuestas a openers v2 (ids seq_* y alias de template) ── + // Va DESPUÉS de Revenue OS (que resuelve su propio "Sí, cuéntame" gateado por + // estado) y ANTES del embudo 100M. Un id seq_* del catálogo NUNCA cae al + // fallback global. + if (replyId && replyId.startsWith('seq_')) { + if (await handleIa360SequenceReply({ record, replyId })) return; + } else if (replyId || answer) { + const aliasKey = String(replyId || answer || '').trim().toLowerCase(); + if (IA360_SEQ_ALIAS_NEGATIVE.has(aliasKey) || IA360_SEQ_ALIAS_HANDOFF.has(aliasKey) || IA360_SEQ_ALIAS_AFFIRMATIVE.has(aliasKey)) { + try { + const aliasContact = await loadIa360ContactContext(record).catch(() => null); + const aliased = resolveIa360TemplateButtonAlias({ replyId: aliasKey, contact: aliasContact }); + if (aliased && await handleIa360SequenceReply({ record, replyId: aliased, contact: aliasContact })) return; + } catch (aliasErr) { + console.error('[ia360-seq] alias error:', aliasErr.message); + } + } + } + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + // G-C anti-loop: "No prioritario" ya NO ofrece "Aplicarlo" (reabría la rama + // comercial); las salidas son nutrición ("Más adelante") o baja. + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + // ── G-C: anti-loop del router 100M ────────────────────────────────────── + // 'baja' (optout) SIEMPRE pasa: la salida del contacto no se bloquea nunca. + if (flow100m.tag !== 'no-contactar') { + try { + const guard = await ia360HundredMAdvancedGuard(record); + // Guard de estado/versión: la conversación ya avanzó a agenda/reunión/ + // handoff humano → un botón de un mensaje viejo NO reabre la rama. + if (guard.advanced) { + await enqueueIa360Text({ record, label: 'ia360_100m_continuity', body: guard.body }); + return; + } + // Nodo loop-prone repetido → versión condensada con salidas terminales, + // no el bloque completo otra vez. Si la lectura del contacto falló, NO se + // escribe el mapa de visitas (se pisaría con un objeto vacío). + const visited = guard.visited || {}; + if (guard.visitedOk && IA360_100M_LOOP_PRONE.has(flow100m.tag)) { + if (visited[flow100m.tag]) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: (Number(visited[flow100m.tag]) || 0) + 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + await enqueueIa360Interactive({ + record, + label: 'ia360_100m_condensed', + messageBody: `IA360 100M: ${flow100m.title} (resumen)`, + interactive: { + type: 'button', + body: { text: `Eso ya lo vimos: ${flow100m.title}. Para no darte vueltas con lo mismo, mejor dime cómo cerramos: ¿agendamos una llamada corta con Alek o lo dejamos para más adelante?` }, + footer: { text: 'IA360 · sin vueltas' }, + action: { + buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada con Alek' } }, + { type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }, + ], + }, + }, + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + } + } catch (guardErr) { + console.error('[ia360-100m] guard error:', guardErr.message); + } + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + // G-LIVE (auditoría 06-11): el prospecto ELIGIÓ hora; si el prompt de + // confirmación falla, no puede quedar mudo en el paso más caliente del + // booking — y Alek debe enterarse para cerrar la cita manualmente. + await enqueueIa360Text({ + record, + label: 'ia360_lite_slot_confirm_fallback', + body: 'Vi que elegiste un horario. Déjame confirmarlo con Calendar y te escribo en un momento para cerrarlo.', + }).catch(() => {}); + await handleIa360BotFailure({ + record, + reason: 'booking: falló el prompt de confirmación de horario (' + String(e.message || '').slice(0, 80) + ')', + alreadyResponded: true, + }).catch(err2 => console.error('[ia360-failure] slot confirm alert error:', err2.message)); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + }, + }); + return; + } + + // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── + // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo o + // malformado). Los ids seq_* del catálogo y los quick replies de template con + // estado persona-first ya se rutean arriba (handleIa360SequenceReply + alias); + // aquí solo cae lo verdaderamente desconocido. El contacto siempre recibe + // acuse y el owner se entera. try/catch terminal: nunca tumba el webhook. + try { + const fallbackId = replyId || answer || '(sin id)'; + console.warn('[ia360-fallback] unhandled interactive reply contact=%s id=%s body=%s', record.contact_number || '-', fallbackId, String(record.message_body || '').slice(0, 80)); + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_ack', + body: `Recibí tu respuesta "${String(record.message_body || fallbackId).slice(0, 60)}", pero aún no tengo una acción conectada para ese botón (${fallbackId}). No hice ningún cambio.`, + }); + return; + } + await enqueueIa360Text({ + record, + label: 'ia360_interactive_fallback', + body: 'Recibí tu respuesta y la estoy ubicando para darte una respuesta útil. Si es urgente, Alek también puede escribirte directo.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_notice', + body: `Alek, ${record.contact_name || record.contact_number} (${record.contact_number}) respondió "${String(record.message_body || fallbackId).slice(0, 60)}" (id: ${fallbackId}) y no tengo un manejador para esa opción. Le acusé recibo; revisa si quieres tomarlo tú.`, + targetContact: record.contact_number, + ownerBudget: true, + }); + } catch (fbErr) { + console.error('[ia360-fallback] interactive fallback error:', fbErr.message); + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + if (!IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number))) return false; + // G-LIVE (auditoría 06-11): el canary es un arnés del OWNER — todo su egress va + // hard-coded al owner. Un contacto real allowlisted por error quedaría 100% mudo + // (el route hace continue y el monolito nunca ve el mensaje). El invariante + // owner-only vive en código, no solo en el .env: un no-owner cae al monolito. + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.error('[brain-v2-canary] allowlist contiene un no-owner %s — cayendo al monolito', + maskIa360Number(record.contact_number)); + return false; + } + return true; +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +// G-LIVE QA-GUARD (incidente José Ramón): detecta payloads SINTÉTICOS del harness +// QA/E2E por su wamid (wamid.e2e.*, wamid.qa-harness.*, e2e.*, *harness*). Los wamid +// reales de Meta son 'wamid.' + base64 (sin más puntos), así que nunca matchean. +function isIa360SyntheticWamid(messageId) { + // Solo el patrón con puntos: el cuerpo base64 de un wamid real no contiene puntos, + // así que el falso positivo (descartar un mensaje real) es estructuralmente imposible. + return /(^|\.)(e2e|qa[-_a-z0-9]*|harness)\./i.test(String(messageId || '')); +} + +// Un payload sintético SOLO puede procesarse para números QA (52199900*) o el owner. +// Cualquier otro destino (contactos reales) se descarta completo: sin insert en +// chat_history, sin handlers, sin egress derivado. +function isIa360BlockedSyntheticInbound(record) { + if (!record || !isIa360SyntheticWamid(record.message_id)) return false; + const n = String(record.contact_number || ''); + return !(IA360_QA_NUMBER_RE.test(n) || n === IA360_OWNER_NUMBER); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + // G-LIVE QA-GUARD: descartar payloads sintéticos dirigidos a números reales + // ANTES de insertar o despachar nada (cierra el incidente José Ramón). + let blockedSynthetic = 0; + for (let i = allRecords.length - 1; i >= 0; i--) { + if (isIa360BlockedSyntheticInbound(allRecords[i])) { + const r = allRecords[i]; + console.warn('[qa-guard] payload sintético bloqueado: wamid=%s contact=%s type=%s', + r.message_id, maskIa360Number(r.contact_number), r.message_type); + allRecords.splice(i, 1); + blockedSynthetic += 1; + } + } + if (blockedSynthetic > 0 && allRecords.length === 0) { + return res.status(200).json({ ok: true, stored: 0, blocked_synthetic: blockedSynthetic }); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // G-LIVE: invariante no-silencio. Se programa ANTES de cualquier handler + // para que cubra continues, throws y ramas fire-and-forget por igual. + scheduleIa360NoSilenceWatchdog(record); + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── EXPEDIENTE: comando del owner "qué sabes de " ── + // Mismo patrón que "idea:": va ANTES del canary Brain v2 (el owner + // está en la allowlist y el canary haría continue). Read-only sobre + // ia360_memory_facts/events; responde SIEMPRE (nunca queda mudo). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const memQuery = parseIa360OwnerMemoryQuery(record.message_body); + if (memQuery) { + await handleIa360OwnerMemoryQuery({ record, query: memQuery }) + .catch(e => console.error('[ia360-expediente] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + // G-WIN cartera: el bot no lee imágenes. Si el contacto está a media + // captura de tabla (esperando_tabla) y manda foto/archivo, pedimos la + // versión en texto y no seguimos el embudo para este record. + if (await handleCarteraMediaInbound(record)) { + continue; + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // G-WIN "Mapa de cartera" (P7 Champions) — mismo contrato que Revenue OS: + // gateado por persona+tema+estado; si actúa, CORTA el embudo para que el + // agente genérico no responda encima (guardrail: sin pitch, sin agenda). + const carteraHandled = revHandled + ? false + : await handleCarteraFreeText(record).catch(e => { console.error('[cartera] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled && !carteraHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + // G-LIVE (auditoría 06-11): un throw en el dispatch no debe dejar mudo al + // contacto. Verificamos la VERDAD en chat_history (no flags) antes del + // fallback; ante error del check, no doble-texteamos. + if (record.direction === 'incoming' + && ['text', 'interactive', 'button'].includes(record.message_type) + && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER + && !(record.message_type === 'text' && isIa360PassiveMessage(record.message_body))) { + const hasOut = await ia360HasOutgoingForInbound(record).catch(() => true); + if (!hasOut) { + await handleIa360BotFailure({ + record, + reason: 'error en dispatch: ' + String(triggerErr.message || 'desconocido').slice(0, 120), + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] dispatch fallback error:', e.message)); + } + } + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ + ok: true, + stored: allRecords.length, + ...(blockedSynthetic > 0 ? { blocked_synthetic: blockedSynthetic } : {}), + }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +// G-WIN cartera — vista previa del flujo completo al WhatsApp del OWNER (nunca +// a un contacto): los mensajes de los 3 pasos con datos de ejemplo, en UN solo +// texto, para aprobación de copy. Egress único: sendIa360DirectText → +// messageSender. Auth = X-IA360-Directive-Secret (patrón de los endpoints +// internos). Idempotencia: el caller decide cuándo (una sola vez por sesión). +router.post('/internal/ia360-cartera/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const ejemploRows = [ + { cuenta: 'Transportes del Bajío', saldo_portal: '$1,250,000.00', saldo_correcto: '$980,000.00', fecha_corte: '31/05/2026', responsable: 'Laura' }, + { cuenta: 'Logística Occidente', saldo_portal: '$430,500.00', saldo_correcto: '$512,300.00', fecha_corte: '31/05/2026', responsable: 'Marco' }, + ]; + const mapaEjemplo = buildCarteraMapa(ejemploRows); + const preview = [ + 'IA360 · PREVIEW flujo "Mapa de cartera" (P7 Champions) — para tu aprobación. Nada de esto se envió a Andrés.', + '', + '── PASO 1 · El contacto reporta saldos que no cuadran. El bot responde: ──', + '', + IA360_CARTERA_COPY.paso1, + '', + '── Si manda foto o archivo en lugar de texto: ──', + '', + IA360_CARTERA_COPY.pideTexto, + '', + '── PASO 2 · El contacto pega la tabla. El bot responde (datos de ejemplo): ──', + '', + mapaEjemplo.texto, + '', + '── PASO 3 · Tú recibes este readout y el deal avanza a "Quick win entregado": ──', + '', + 'IA360 · Quick win cartera — (contacto) (521***XX)', + '', + 'El contacto entregó su tabla de cartera (2 cuentas) y le devolví el mapa estructurado.', + `- Cuentas con descuadre: ${mapaEjemplo.cuentasConDescuadre} · Diferencia acumulada: ${carteraFormatoMonto(mapaEjemplo.diferenciaTotal)}`, + '- Deal: «IA360 · (contacto) · Quick win cartera» → Quick win entregado (P7 Champions).', + '- Mapa encolado a ia360_docs_sync (destino AlekContenido).', + ].join('\n'); + const record = { + wa_number: normalizePhone(req.body?.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'), + contact_number: IA360_OWNER_NUMBER, + message_id: `cartera_preview:${Date.now()}`, + message_type: 'cartera_preview', + message_body: '', + }; + const sent = await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_cartera_preview_owner', + body: preview, + }); + return res.status(sent ? 200 : 502).json({ ok: !!sent, schema: 'ia360_cartera_preview.v1', chars: preview.length }); + } catch (err) { + console.error('[cartera] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// OPENERS V2 — vista previa de un opener al WhatsApp del OWNER (nunca a un +// contacto). Renderiza el draft v2 (primer nombre + quien_intro opcional) y el +// interactive (botones/lista) tal como lo vería el contacto. Único egress: +// sendOwnerInteractive / sendIa360DirectText -> messageSender. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). +router.post('/internal/ia360-openers/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const sequenceId = String(b.sequence_id || '').trim().toLowerCase(); + const found = findIa360SequenceFlow(sequenceId); + if (!found) return res.status(422).json({ ok: false, error: 'unknown_sequence', sequence_id: sequenceId }); + const { sequence } = found; + const sampleName = ia360FirstNameFrom(String(b.name || 'Alek').trim() || 'Alek'); + const quienIntro = String(b.quien_intro || '').trim() || null; + const bodyText = typeof sequence.draft === 'function' ? sequence.draft({ name: sampleName, quienIntro }) : String(sequence.draft || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const synthetic = { + wa_number: waNumber, + contact_number: IA360_OWNER_NUMBER, + message_id: `opener-preview-${sequenceId}-${Date.now()}`, + message_type: 'text', + direction: 'incoming', + }; + const interactive = buildIa360OpenerInteractive({ sequence, bodyText }); + let sent; + if (interactive) { + // ownerBudget=false: la preview es una petición explícita del owner; no debe + // caer en el presupuesto anti-spam de notificaciones. + sent = await sendOwnerInteractive({ + record: synthetic, + label: `ia360_opener_preview_${sequenceId}`, + messageBody: `IA360 preview opener ${sequenceId}`, + interactive, + }); + } else { + sent = await sendIa360DirectText({ + record: synthetic, + toNumber: IA360_OWNER_NUMBER, + label: `ia360_opener_preview_${sequenceId}`, + body: bodyText, + }); + } + return res.status(sent ? 200 : 502).json({ + ok: Boolean(sent), + schema: 'ia360_opener_preview.v1', + sequence_id: sequenceId, + kind: interactive ? (interactive.type === 'list' ? 'list' : 'buttons') : 'text', + body_preview: bodyText, + }); + } catch (err) { + console.error('[ia360-openers] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-gc-20260610T182557Z b/backend/src/routes/webhook.js.bak-pre-gc-20260610T182557Z new file mode 100644 index 0000000..aa55e49 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-gc-20260610T182557Z @@ -0,0 +1,6961 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + // quien_intro (D6): si el vCard lo comparte un CONTACTO (no el owner), esa + // persona es quien hizo la introducción. Se guarda el NOMBRE para que el + // opener referido_contexto pueda decir "nos presentó X". Si lo manda el owner, + // el dato queda pendiente (el placeholder {{quien_intro}} bloquea el copy). + let quienIntro = null; + if (record.contact_number && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + try { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(record.contact_number)] + ); + quienIntro = String(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || '').trim() || null; + } catch (e) { + console.error('[ia360-vcard] quien_intro lookup:', e.message); + } + } + const customFields = { + ...(quienIntro ? { quien_intro: quienIntro } : {}), + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_architectura:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_architectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_architectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_feedback:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_feedback:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_feedback:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_memoria:si_a_ver', title: 'Sí, a ver' }, + { id: 'seq_beta_memoria:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_memoria:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_contexto:pregunta', title: 'Hazme una pregunta' }, + { id: 'seq_referido_contexto:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_contexto:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_oneliner:si_cuentame', title: 'Sí, cuéntame más' }, + { id: 'seq_referido_oneliner:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_oneliner:ahora_no', title: 'Por ahora no' }, + ], + }, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, + metaTemplateName: 'ia360_referido_apertura', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_permiso_agenda:horarios', title: 'Proponme horarios' }, + { id: 'seq_referido_permiso_agenda:pregunta', title: 'Primero una pregunta' }, + { id: 'seq_referido_permiso_agenda:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, + metaTemplateName: 'ia360_aliado_mapa_colaboracion', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_mapa_colaboracion:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_mapa_colaboracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_mapa_colaboracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_criterios_fit:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_criterios_fit:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_criterios_fit:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_caso_reventa:si_comparte', title: 'Sí, compártelo' }, + { id: 'seq_aliado_caso_reventa:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_caso_reventa:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_readout:si_cuento', title: 'Sí, te cuento' }, + { id: 'seq_cliente_readout:todo_bien', title: 'Todo va bien' }, + { id: 'seq_cliente_readout:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_soporte:hay_tema', title: 'Sí, hay un tema' }, + { id: 'seq_cliente_soporte:todo_orden', title: 'Todo en orden' }, + { id: 'seq_cliente_soporte:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te escribo de su parte porque tú y Alek ya tienen un proyecto andando, y Alek quiere ubicar dónde estaría el siguiente paso con más impacto, sin empujarte nada fuera de tiempo. De estas áreas, ¿cuál te quita más tiempo hoy?`, + requiresLiveDeal: true, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cliente_expansion:whatsapp', title: 'WhatsApp y mensajes' }, + { id: 'seq_cliente_expansion:crm', title: 'CRM y clientes' }, + { id: 'seq_cliente_expansion:datos', title: 'Datos y reportes' }, + { id: 'seq_cliente_expansion:agenda', title: 'Agenda y citas' }, + { id: 'seq_cliente_expansion:seguimiento', title: 'Seguimiento de ventas' }, + { id: 'seq_cliente_expansion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte antes de mandarte una demo genérica: prefiere ubicar primero dónde habría valor real para tu operación. De estas áreas, ¿dónde sientes el cuello de botella que más mueve la aguja?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_sponsor_diagnostico:operacion', title: 'Operación' }, + { id: 'seq_sponsor_diagnostico:ventas', title: 'Ventas' }, + { id: 'seq_sponsor_diagnostico:datos', title: 'Datos y reportes' }, + { id: 'seq_sponsor_diagnostico:seguimiento', title: 'Seguimiento' }, + { id: 'seq_sponsor_diagnostico:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir fuga', + options: [ + { id: 'seq_sponsor_fuga_valor:tiempo', title: 'Tiempo perdido' }, + { id: 'seq_sponsor_fuga_valor:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_sponsor_fuga_valor:datos', title: 'Datos poco confiables' }, + { id: 'seq_sponsor_fuga_valor:decisiones', title: 'Decisiones lentas' }, + { id: 'seq_sponsor_fuga_valor:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_sponsor_caso_ndasafe:si_manda', title: 'Sí, mándalo' }, + { id: 'seq_sponsor_caso_ndasafe:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_sponsor_caso_ndasafe:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja ayudando a equipos comerciales y casi siempre el problema aparece en uno de tres lugares. En tu equipo, ¿cuál duele más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_pipeline:leads', title: 'Leads que no llegan' }, + { id: 'seq_comercial_pipeline:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_comercial_pipeline:contexto', title: 'WhatsApp sin contexto' }, + { id: 'seq_comercial_pipeline:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_wa_crm:historial', title: 'Historial de clientes' }, + { id: 'seq_comercial_wa_crm:seguimiento', title: 'Seguimiento' }, + { id: 'seq_comercial_wa_crm:prioridad', title: 'Prioridad de leads' }, + { id: 'seq_comercial_wa_crm:datos', title: 'Datos para decidir' }, + { id: 'seq_comercial_wa_crm:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_motor_prospeccion:segmento', title: 'Segmento claro' }, + { id: 'seq_comercial_motor_prospeccion:mensaje', title: 'Mensaje repetible' }, + { id: 'seq_comercial_motor_prospeccion:seguimiento', title: 'Seguimiento medible' }, + { id: 'seq_comercial_motor_prospeccion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja con equipos de finanzas que terminan operando a mano porque no pueden confiar rápido en sus datos. En tu caso, ¿dónde está el mayor dolor hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cfo_control:cartera', title: 'Cartera' }, + { id: 'seq_cfo_control:comisiones', title: 'Comisiones' }, + { id: 'seq_cfo_control:reportes', title: 'Reportes' }, + { id: 'seq_cfo_control:conciliacion', title: 'Conciliación' }, + { id: 'seq_cfo_control:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cfo_cartera_datos:respondo', title: 'Te respondo aquí' }, + { id: 'seq_cfo_cartera_datos:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_cfo_cartera_datos:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_cfo_comisiones:reglas', title: 'Reglas manuales' }, + { id: 'seq_cfo_comisiones:excepciones', title: 'Excepciones' }, + { id: 'seq_cfo_comisiones:datos', title: 'Datos que no cuadran' }, + { id: 'seq_cfo_comisiones:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_arquitectura:mapa', title: 'Mándame el mapa' }, + { id: 'seq_tecnico_arquitectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_arquitectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada?`, + openerOptions: { + kind: 'list', + button: 'Elegir riesgo', + options: [ + { id: 'seq_tecnico_rollback:permisos', title: 'Permisos' }, + { id: 'seq_tecnico_rollback:datos', title: 'Datos' }, + { id: 'seq_tecnico_rollback:trazabilidad', title: 'Trazabilidad' }, + { id: 'seq_tecnico_rollback:reversibilidad', title: 'Reversibilidad' }, + { id: 'seq_tecnico_rollback:dependencia', title: 'Dependencia operativa' }, + { id: 'seq_tecnico_rollback:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_integracion:respondo', title: 'Te respondo aquí' }, + { id: 'seq_tecnico_integracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_integracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +// Openers v2: saludo con primer nombre (D9). Limpia el sufijo de QA y toma el +// primer token; si no hay nada usable devuelve el valor original. +function ia360FirstNameFrom(name) { + const raw = String(name || '').trim().replace(/\s+WhatsApp IA360$/i, '').trim(); + return raw.split(/\s+/).filter(Boolean)[0] || raw; +} + +// Openers v2: arma el objeto `interactive` de un opener desde sequence.openerOptions +// (kind 'buttons' ≤3 opciones, kind 'list' 4+). Sin header ni footer: el copy +// aprobado por Alek va fiel en body.text. Devuelve null si la secuencia no tiene +// openerOptions (esas siguen saliendo como texto plano). +function buildIa360OpenerInteractive({ sequence, bodyText }) { + const opts = sequence && sequence.openerOptions; + if (!opts || !Array.isArray(opts.options) || !opts.options.length) return null; + if (opts.kind === 'list') { + return { + type: 'list', + body: { text: bodyText }, + action: { + button: opts.button || 'Elegir', + sections: [{ + title: 'Opciones', + rows: opts.options.map(o => ({ id: o.id, title: o.title, ...(o.description ? { description: o.description } : {}) })), + }], + }, + }; + } + return { + type: 'button', + body: { text: bodyText }, + action: { + buttons: opts.options.slice(0, 3).map(o => ({ type: 'reply', reply: { id: o.id, title: o.title } })), + }, + }; +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + // Openers v2: saludo con primer nombre (D9) + quién hizo la introducción (D6). + const draftName = ia360FirstNameFrom(name); + const quienIntro = String(customFields.quien_intro || '').trim() || null; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name: draftName, quienIntro }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + ...(sequence.openerOptions && Array.isArray(sequence.openerOptions.options) + ? ['', `Opciones del mensaje (${sequence.openerOptions.kind === 'list' ? 'lista' : 'botones'}): ${sequence.openerOptions.options.map(o => o.title).join(' | ')}`] + : []), + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { + text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, + }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows: [ + { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ], + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // GUARDIA cliente_expansion (D7): la secuencia presupone un proyecto andando. + // Solo dispara si el contacto tiene un deal vivo (status='open') en P2 (IA360 + // WhatsApp Revenue Pipeline) o P7 (Champions). Sin deal vivo → bloquear con aviso. + if (sequence.requiresLiveDeal) { + const { rows: liveRows } = await pool.query( + `SELECT 1 + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') + AND d.contact_wa_number = $1 + AND d.contact_number = $2 + AND d.status = 'open' + LIMIT 1`, + [record.wa_number, targetContact] + ); + if (!liveRows.length) { + return deny('no_live_deal', `${name} no tiene un proyecto activo (deal vivo en P2/P7). La secuencia ${sequence.id} solo aplica a clientes con proyecto en curso; elige otra secuencia. No envié nada.`); + } + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); + // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + // Openers v2: dentro de ventana el opener sale como interactive (botones/lista) + // con el copy aprobado en el readout; secuencias sin openerOptions siguen en texto. + const openerInteractive = buildIa360OpenerInteractive({ sequence, bodyText: pf.sequence_candidate.draft }); + let sent; + let handlerFor; + if (openerInteractive) { + sent = await enqueueIa360Interactive({ + record: targetRecord, + label: openerLabel, + messageBody: `IA360 opener: ${sequence.label}`, + interactive: openerInteractive, + dedupSuffix: `:opener:${targetContact}`, + }); + handlerFor = `${record.message_id}:opener:${targetContact}`; + } else { + sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + handlerFor = `${record.message_id}:direct:${targetContact}`; + } + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(handlerFor); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── Expediente del owner: "qué sabes de " ────────────────── +// Comando read-only del owner: arma un expediente con los facts y eventos de +// coexistence.ia360_memory_* para un contacto, resuelto por número o por +// nombre (tolerante a acentos y a typos simples tipo Emmanuel/Emanuel). +// Egress SOLO vía sendIa360DirectText; nunca escribe memoria y SIEMPRE +// responde algo (sin expediente / candidatos / error), nunca queda mudo. +const IA360_BOT_WA_NUMBER = '5213321594582'; // número del bot: jamás es contacto + +function parseIa360OwnerMemoryQuery(body) { + const text = String(body || '').trim(); + const m = text.match(/^¿?\s*(?:qu[eé]|qui[eé]n)\s+sabes\s+(?:de\s+la|de\s+el|del|de|sobre)\s+(.+?)\s*\?*$/i); + if (!m) return null; + const q = m[1].trim(); + return q || null; +} + +// Normaliza para comparar nombres: minúsculas, sin acentos y con letras +// repetidas colapsadas ("Emmanuel" y "Emanuel" → "emanuel"). +function ia360NormalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +async function resolveIa360MemoryTarget(query) { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + // Número directo: 10 dígitos MX → prefijo 521 (formato ForgeChat). + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const { rows } = await pool.query( + `SELECT DISTINCT contact_number, contact_name FROM ( + SELECT contact_number, contact_name + FROM coexistence.ia360_memory_events + WHERE contact_name IS NOT NULL AND contact_number IS NOT NULL + UNION ALL + SELECT contact_number, COALESCE(name, profile_name) AS contact_name + FROM coexistence.contacts + WHERE COALESCE(name, profile_name) IS NOT NULL AND contact_number IS NOT NULL + ) t + WHERE contact_number <> $1`, + [IA360_BOT_WA_NUMBER] + ); + const needle = ia360NormalizeNameForMatch(query); + if (!needle) return { kind: 'none', candidates: [] }; + const byNumber = new Map(); + for (const r of rows) { + if (!ia360NormalizeNameForMatch(r.contact_name).includes(needle)) continue; + if (!byNumber.has(r.contact_number)) byNumber.set(r.contact_number, r); + } + const candidates = [...byNumber.values()]; + if (!candidates.length) return { kind: 'none', candidates: [] }; + if (candidates.length > 1) return { kind: 'ambiguous', candidates }; + return { kind: 'name', candidates }; +} + +async function buildIa360ContactDossier(contactNumber) { + const num = normalizePhone(contactNumber); + const { rows: factRows } = await pool.query( + `SELECT project_name, persona, role, account_name, preference, objection, + recurring_pain, affected_process, missing_metric + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + ORDER BY last_seen_at DESC, id DESC`, + [num] + ); + const { rows: eventRows } = await pool.query( + `SELECT contact_name, area, signal_type, summary + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC, id DESC + LIMIT 12`, + [num] + ); + if (!factRows.length && !eventRows.length) return null; + + const name = eventRows.find(e => e.contact_name)?.contact_name || null; + const header = [`Expediente IA360: ${name || 'contacto'} (${num})`]; + const meta = []; + const accountRow = factRows.find(f => f.account_name); + const projectRow = factRows.find(f => f.project_name); + const personaRow = factRows.find(f => f.persona); + if (accountRow) meta.push(`Cuenta: ${accountRow.account_name}`); + if (projectRow) meta.push(`Proyecto: ${projectRow.project_name}`); + if (personaRow) meta.push(`Persona: ${personaRow.persona}`); + if (meta.length) header.push(meta.join(' · ')); + + // Los facts viven duplicados por el doble keying de contact_wa_number + // (monolito vs lookup v2): dedupe por contenido, no por fila. + const factLines = []; + const seenFacts = new Set(); + for (const f of factRows) { + for (const field of ['preference', 'objection', 'recurring_pain', 'affected_process', 'missing_metric']) { + const val = String(f[field] || '').trim(); + if (!val) continue; + const key = `${field}:${val}`; + if (seenFacts.has(key)) continue; + seenFacts.add(key); + factLines.push(`- ${val.length > 300 ? `${val.slice(0, 297)}...` : val}`); + } + } + const eventLines = []; + const seenEvents = new Set(); + for (const e of eventRows) { + const val = String(e.summary || '').trim(); + if (!val) continue; + const key = `${e.area}|${e.signal_type}|${val}`; + if (seenEvents.has(key)) continue; + seenEvents.add(key); + eventLines.push(`- [${e.area}/${e.signal_type}] ${val.length > 220 ? `${val.slice(0, 217)}...` : val}`); + } + + const lines = [...header, '']; + if (factLines.length) lines.push(`Facts (${factLines.length}):`, ...factLines, ''); + if (eventLines.length) lines.push(`Eventos recientes (${eventLines.length}):`, ...eventLines); + let body = lines.join('\n').trim(); + // Límite duro de WhatsApp: 4096 chars por texto. + if (body.length > 3900) body = `${body.slice(0, 3880)}\n... (recortado)`; + return body; +} + +async function handleIa360OwnerMemoryQuery({ record, query }) { + let body; + try { + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + body = `Sin expediente: no encontré facts ni eventos para "${query}". Revisa el nombre o mándame el número completo.`; + } else if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + body = `Encontré varios contactos que coinciden con "${query}". ¿De cuál quieres el expediente?\n${list}\n\nMándame "qué sabes de " para verlo.`; + } else { + const dossier = await buildIa360ContactDossier(target.candidates[0].contact_number); + body = dossier + || `Sin expediente: el contacto ${target.candidates[0].contact_number} no tiene facts ni eventos guardados todavía.`; + } + } catch (err) { + console.error('[ia360-expediente] dossier error:', err.message); + body = `No pude leer el expediente de "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); +} + +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + }, + }); + return; + } + + // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── + // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo, + // id de opener v2 sin ruteo todavía, o quick reply de template sin estado, + // p. ej. "Sí, cuéntame" de ia360_referido_apertura). Antes el contacto quedaba + // MUDO; ahora siempre recibe acuse y el owner se entera. try/catch terminal: + // nunca tumba el webhook. + try { + const fallbackId = replyId || answer || '(sin id)'; + console.warn('[ia360-fallback] unhandled interactive reply contact=%s id=%s body=%s', record.contact_number || '-', fallbackId, String(record.message_body || '').slice(0, 80)); + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_ack', + body: `Recibí tu respuesta "${String(record.message_body || fallbackId).slice(0, 60)}", pero aún no tengo una acción conectada para ese botón (${fallbackId}). No hice ningún cambio.`, + }); + return; + } + await enqueueIa360Text({ + record, + label: 'ia360_interactive_fallback', + body: 'Recibí tu respuesta y la estoy ubicando para darte una respuesta útil. Si es urgente, Alek también puede escribirte directo.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_notice', + body: `Alek, ${record.contact_name || record.contact_number} (${record.contact_number}) respondió "${String(record.message_body || fallbackId).slice(0, 60)}" (id: ${fallbackId}) y no tengo un manejador para esa opción. Le acusé recibo; revisa si quieres tomarlo tú.`, + targetContact: record.contact_number, + ownerBudget: true, + }); + } catch (fbErr) { + console.error('[ia360-fallback] interactive fallback error:', fbErr.message); + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── EXPEDIENTE: comando del owner "qué sabes de " ── + // Mismo patrón que "idea:": va ANTES del canary Brain v2 (el owner + // está en la allowlist y el canary haría continue). Read-only sobre + // ia360_memory_facts/events; responde SIEMPRE (nunca queda mudo). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const memQuery = parseIa360OwnerMemoryQuery(record.message_body); + if (memQuery) { + await handleIa360OwnerMemoryQuery({ record, query: memQuery }) + .catch(e => console.error('[ia360-expediente] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +// OPENERS V2 — vista previa de un opener al WhatsApp del OWNER (nunca a un +// contacto). Renderiza el draft v2 (primer nombre + quien_intro opcional) y el +// interactive (botones/lista) tal como lo vería el contacto. Único egress: +// sendOwnerInteractive / sendIa360DirectText -> messageSender. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). +router.post('/internal/ia360-openers/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const sequenceId = String(b.sequence_id || '').trim().toLowerCase(); + const found = findIa360SequenceFlow(sequenceId); + if (!found) return res.status(422).json({ ok: false, error: 'unknown_sequence', sequence_id: sequenceId }); + const { sequence } = found; + const sampleName = ia360FirstNameFrom(String(b.name || 'Alek').trim() || 'Alek'); + const quienIntro = String(b.quien_intro || '').trim() || null; + const bodyText = typeof sequence.draft === 'function' ? sequence.draft({ name: sampleName, quienIntro }) : String(sequence.draft || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const synthetic = { + wa_number: waNumber, + contact_number: IA360_OWNER_NUMBER, + message_id: `opener-preview-${sequenceId}-${Date.now()}`, + message_type: 'text', + direction: 'incoming', + }; + const interactive = buildIa360OpenerInteractive({ sequence, bodyText }); + let sent; + if (interactive) { + // ownerBudget=false: la preview es una petición explícita del owner; no debe + // caer en el presupuesto anti-spam de notificaciones. + sent = await sendOwnerInteractive({ + record: synthetic, + label: `ia360_opener_preview_${sequenceId}`, + messageBody: `IA360 preview opener ${sequenceId}`, + interactive, + }); + } else { + sent = await sendIa360DirectText({ + record: synthetic, + toNumber: IA360_OWNER_NUMBER, + label: `ia360_opener_preview_${sequenceId}`, + body: bodyText, + }); + } + return res.status(sent ? 200 : 502).json({ + ok: Boolean(sent), + schema: 'ia360_opener_preview.v1', + sequence_id: sequenceId, + kind: interactive ? (interactive.type === 'list' ? 'list' : 'buttons') : 'text', + body_preview: bodyText, + }); + } catch (err) { + console.error('[ia360-openers] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-gcold-20260610T185011Z b/backend/src/routes/webhook.js.bak-pre-gcold-20260610T185011Z new file mode 100644 index 0000000..9a49218 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-gcold-20260610T185011Z @@ -0,0 +1,8058 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +// G-C: el nombre del introductor viene de push name / vCard (texto controlado por +// el remitente). Se sanitiza antes de persistir: sin caracteres de control ni +// saltos de línea, sin llaves de placeholder, espacios colapsados y tope de 60 +// caracteres. Devuelve null si no queda nada usable. +function sanitizeIa360IntroName(raw) { + const clean = String(raw || '') + .replace(/[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ' ') + .replace(/[{}]/g, '') + .replace(/\s+/g, ' ') + .trim(); + // Corte por code points (no por unidades UTF-16): un emoji en la frontera de + // los 60 caracteres no deja un surrogate suelto que rompa el jsonb al persistir. + const capped = Array.from(clean).slice(0, 60).join('').trim(); + if (!capped) return null; + if (!/[\p{L}]/u.test(capped)) return null; // sin letras (solo dígitos/símbolos) no sirve como nombre + return capped; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + // quien_intro (D6): si el vCard lo comparte un CONTACTO (no el owner), esa + // persona es quien hizo la introducción. Se guarda el NOMBRE para que el + // opener referido_contexto pueda decir "nos presentó X". Si lo manda el owner, + // el dato queda pendiente (el placeholder {{quien_intro}} bloquea el copy). + // G-C: sanitizado (push name inyectable), sin auto-introducción (vCard propio) + // y sin pisar un quien_intro ya capturado. + let quienIntro = null; + const sharerIsSelf = normalizePhone(record.contact_number || '') === normalizePhone(shared.contactNumber || ''); + if (record.contact_number && !sharerIsSelf && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + try { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(record.contact_number)] + ); + quienIntro = sanitizeIa360IntroName(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || ''); + if (quienIntro) { + const { rows: existingRows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(shared.contactNumber)] + ); + if (String(existingRows[0]?.quien_intro || '').trim()) quienIntro = null; // ya hay introductor registrado: no pisar + } + } catch (e) { + console.error('[ia360-vcard] quien_intro lookup:', e.message); + quienIntro = null; + } + } + const customFields = { + ...(quienIntro ? { quien_intro: quienIntro } : {}), + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +// ============================================================================ +// G-WIN — Quick-win "Mapa de cartera" (Pipeline 7 "Champions — Adopción y +// expansión", persona cliente activo / CFO). Patrón Revenue OS P5: máquina de +// estados en contacts.custom_fields.ia360_cartera_state ('' → esperando_tabla +// → mapa_entregado), handlers gateados que CORTAN el embudo, egress único vía +// enqueueIa360Text / sendIa360DirectText → sendQueue. +// PASO 1 (texto cartera/saldos que no cuadran) → Hallazgo / Impacto / Dato +// faltante (pide la tabla EN TEXTO; el bot no lee imágenes) / +// Siguiente acción. SIN pitch y SIN agenda. +// PASO 2 (tabla pegada en texto) → mapa estructurado al contacto + nota +// completa en su deal P7 + cola ia360_docs_sync + readout al owner + +// deal a "Quick win entregado" (solo hacia adelante). +// GUARDRAIL: nunca agenda automática, no nutrición, no insistencia; si el +// mensaje no es de cartera, el flujo NO se activa y el agente genérico sigue. +// ============================================================================ +const CHAMPIONS_PIPELINE_NAME = 'Champions — Adopción y expansión'; +const CARTERA_STAGE_VALIDACION = 'Validación en curso'; +const CARTERA_STAGE_QUICKWIN = 'Quick win entregado'; +const CARTERA_FORMATO_TABLA = 'Cliente | Saldo en portal | Saldo correcto | Fecha de corte | Responsable'; + +const IA360_CARTERA_COPY = { + paso1: [ + 'Gracias por el aviso. Lo dejo ordenado:', + '', + '*Hallazgo:* los saldos que muestra el portal no cuadran con los saldos reales de cartera; hoy la corrección depende de revisiones manuales y la diferencia no se ve en un solo lugar.', + '', + '*Impacto:* mientras el portal muestre saldos incorrectos, cobranza trabaja con cifras que el cliente puede rebatir y el seguimiento pierde confiabilidad.', + '', + '*Dato faltante:* mándame la tabla aquí mismo, en texto, una línea por cuenta con este formato:', + CARTERA_FORMATO_TABLA, + 'Importante: no puedo leer imágenes ni archivos adjuntos; si la tienes en foto o en Excel, pégamela como texto.', + '', + '*Siguiente acción:* en cuanto la reciba, la convierto en tu mapa de cartera (cuenta → saldo portal → saldo correcto → fecha de corte → responsable → siguiente acción) y lo dejo registrado para Alek.', + ].join('\n'), + pideTexto: [ + 'Recibí tu archivo, pero no puedo leer imágenes ni documentos adjuntos.', + '¿Me pegas la tabla aquí mismo en texto? Una línea por cuenta:', + CARTERA_FORMATO_TABLA, + ].join('\n'), + recordatorioFormato: [ + 'Va, sigo pendiente de la tabla para armar el mapa. Pégala aquí en texto, una línea por cuenta:', + CARTERA_FORMATO_TABLA, + ].join('\n'), +}; + +// Persona cliente activo / CFO: reúsa el helper beta (Andrés) y el perfil +// persona-first (QA y contactos nuevos). El owner JAMÁS entra a este flujo. +function ia360IsClienteActivoCartera(contact) { + if (!contact) return false; + if (isIa360ClienteActivoBetaContact(contact)) return true; + const cf = contact.custom_fields || {}; + const rel = cf?.ia360_persona_first?.classification?.relationship_context || ''; + const personaCtx = String(cf.persona_context || '').toLowerCase(); + const tags = Array.isArray(contact.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return rel === 'cliente_activo' + || personaCtx === 'cliente activo' + || tags.includes('persona:cliente_activo'); +} + +// Disparador del PASO 1: cartera/cobranza explícita, o "saldos" acompañado de +// señal de descuadre. Mantenerlo angosto: un tema no-cartera NO debe activar +// el flujo (gate del goal). +const IA360_CARTERA_TRIGGER_RE = /\b(cartera|cobranza|cuentas?\s+por\s+cobrar)\b/i; +const IA360_CARTERA_SALDOS_RE = /\bsaldos?\b/i; +const IA360_CARTERA_DESCUADRE_RE = /no\s+cuadra|descuadr|incorrect|equivocad|diferenc|portal|\bmal\b/i; + +function ia360EsMensajeCartera(body) { + const t = String(body || ''); + return IA360_CARTERA_TRIGGER_RE.test(t) + || (IA360_CARTERA_SALDOS_RE.test(t) && IA360_CARTERA_DESCUADRE_RE.test(t)); +} + +// Parser de la tabla pegada en texto. Separadores: | ; tab. Coma solo si la +// línea no trae montos con coma de millares ("1,250,000"). Salta encabezados. +function parseCarteraTabla(text) { + const rows = []; + const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean); + for (const line of lines) { + let parts = null; + if (/[|;\t]/.test(line)) parts = line.split(/[|;\t]/); + else if (line.includes(',') && !/\d,\d{3}/.test(line)) parts = line.split(','); + if (!parts) continue; + parts = parts.map(p => p.trim()).filter(p => p !== ''); + if (parts.length < 4) continue; + const low = line.toLowerCase(); + if (/cliente|cuenta/.test(low) && /saldo/.test(low)) continue; // encabezado + rows.push({ + cuenta: parts[0], + saldo_portal: parts[1], + saldo_correcto: parts[2], + fecha_corte: parts[3], + responsable: parts[4] || 'por confirmar', + }); + } + return rows; +} + +function carteraMonto(s) { + const limpio = String(s || '').replace(/[^0-9.\-]/g, ''); + if (!limpio || limpio === '-' || limpio === '.') return null; + const n = Number(limpio); + return Number.isFinite(n) ? n : null; +} + +function carteraFormatoMonto(n) { + if (n === null || !Number.isFinite(n)) return null; + const negativo = n < 0; + const [ent, dec] = Math.abs(n).toFixed(2).split('.'); + return `${negativo ? '-' : ''}$${ent.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${dec}`; +} + +// Mapa estructurado: cuenta → saldo portal → saldo correcto → diferencia → +// fecha de corte → responsable → siguiente acción. +function buildCarteraMapa(rows) { + const bloques = []; + let diferenciaTotal = 0; + let cuentasConDescuadre = 0; + rows.forEach((r, i) => { + const portal = carteraMonto(r.saldo_portal); + const correcto = carteraMonto(r.saldo_correcto); + const dif = portal !== null && correcto !== null ? correcto - portal : null; + if (dif !== null) { + diferenciaTotal += dif; + if (dif !== 0) cuentasConDescuadre += 1; + } + bloques.push([ + `${i + 1}) Cuenta: ${r.cuenta}`, + ` - Saldo en portal: ${carteraFormatoMonto(portal) || r.saldo_portal}`, + ` - Saldo correcto: ${carteraFormatoMonto(correcto) || r.saldo_correcto}`, + ` - Diferencia: ${dif !== null ? carteraFormatoMonto(dif) : 'por calcular'}`, + ` - Fecha de corte: ${r.fecha_corte}`, + ` - Responsable: ${r.responsable}`, + ` - Siguiente acción: corregir el saldo en el portal y confirmarlo con ${r.responsable} antes del próximo corte.`, + ].join('\n')); + }); + const texto = [ + '*Mapa de cartera — saldos por corregir*', + '', + bloques.join('\n\n'), + '', + `Cuentas con descuadre: ${cuentasConDescuadre} de ${rows.length} · Diferencia acumulada: ${carteraFormatoMonto(diferenciaTotal) || 'por calcular'}`, + '', + 'Ya quedó registrado para Alek con el detalle completo. Cuando el portal refleje los saldos correctos, este mapa sirve para confirmarlo cuenta por cuenta.', + ].join('\n'); + return { texto, diferenciaTotal, cuentasConDescuadre }; +} + +// Movimiento de deal dedicado a Pipeline 7 (clon del patrón syncRevenueOsDeal: +// create-or-move, solo hacia adelante por posición; NO toca otros pipelines). +async function syncCarteraChampionsDeal({ record, targetStageName, notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [CHAMPIONS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const title = `IA360 · ${contactName} · Quick win cartera`; + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name, title }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + notes = $3, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $4`, + [finalStageId, shouldMove ? finalStatus : existing.status, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name, title: existing.title }; +} + +// Media (imagen/documento) durante esperando_tabla → pedir la versión en texto. +// El bot no descarga ni interpreta el archivo; solo guía al contacto. +async function handleCarteraMediaInbound(record) { + try { + if (!record || record.direction !== 'incoming') return false; + if (record.message_type !== 'image' && record.message_type !== 'document') return false; + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || !ia360IsClienteActivoCartera(contact)) return false; + if ((contact.custom_fields?.ia360_cartera_state || '') !== 'esperando_tabla') return false; + await enqueueIa360Text({ record, label: 'ia360_cartera_pide_texto', body: IA360_CARTERA_COPY.pideTexto }); + return true; + } catch (err) { + console.error('[cartera] media handler error (no route):', err.message); + return false; + } +} + +// Readout al owner tras entregar el mapa (PASO 2). ownerBudget=false: un quick +// win entregado siempre se reporta. +function buildCarteraOwnerReadout({ record, contactName, deal, mapa, rows }) { + return [ + `IA360 · Quick win cartera — ${contactName || 'contacto'} (${maskIa360Number(record.contact_number)})`, + '', + `El contacto entregó su tabla de cartera (${rows.length} ${rows.length === 1 ? 'cuenta' : 'cuentas'}) y le devolví el mapa estructurado.`, + `- Cuentas con descuadre: ${mapa.cuentasConDescuadre} · Diferencia acumulada: ${carteraFormatoMonto(mapa.diferenciaTotal) || 'por calcular'}`, + deal ? `- Deal: «${deal.title || 'sin título'}» → ${deal.stage} (P7 Champions).` : '- Deal: no se encontró deal en P7 (revisar).', + '- Mapa encolado a ia360_docs_sync (destino AlekContenido).', + '', + 'No envié pitch ni agenda; el flujo quedó en modo quick win.', + ].join('\n'); +} + +// PASO 1 + PASO 2 — texto libre. Va DESPUÉS de Revenue OS y ANTES del agente +// genérico en el dispatch; devuelve true para CORTAR el embudo (guardrail: el +// agente no debe responder encima ni empujar agenda). +async function handleCarteraFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || !ia360IsClienteActivoCartera(contact)) return false; + const state = contact.custom_fields?.ia360_cartera_state || ''; + + // PASO 2 — esperando la tabla. + if (state === 'esperando_tabla') { + const rows = parseCarteraTabla(body); + if (rows.length > 0) { + const mapa = buildCarteraMapa(rows); + const deal = await syncCarteraChampionsDeal({ + record, + targetStageName: CARTERA_STAGE_QUICKWIN, + notes: `PASO 2 mapa de cartera: tabla recibida (${rows.length} cuentas). Quick win entregado.\nTabla original:\n${body}\n\n${mapa.texto}`, + }).catch(e => { console.error('[cartera] deal quick win:', e.message); return null; }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['cartera-quickwin-entregado'], + customFields: { + ia360_cartera_state: 'mapa_entregado', + ia360_cartera_mapa_at: new Date().toISOString(), + ia360_cartera_cuentas: rows.length, + ia360_cartera_tabla_raw: body, + }, + }).catch(e => console.error('[cartera] estado mapa_entregado:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_cartera_mapa', body: mapa.texto }); + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) + VALUES (NULL, $1, $2, 'AlekContenido')`, + [ + `Mapa de cartera — ${contact.name || record.contact_number} (${new Date().toISOString().slice(0, 10)})`, + `${mapa.texto}\n\n---\nTabla original pegada por el contacto:\n${body}`, + ] + ).catch(e => console.error('[cartera] docs_sync:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_cartera_readout', + body: buildCarteraOwnerReadout({ record, contactName: contact.name, deal, mapa, rows }), + targetContact: record.contact_number, + }).catch(e => console.error('[cartera] owner readout:', e.message)); + return true; + } + // Sin tabla todavía: si insiste en el tema, recordamos el formato; si + // habla de otra cosa, el agente genérico responde (respuesta siempre útil). + if (ia360EsMensajeCartera(body)) { + await enqueueIa360Text({ record, label: 'ia360_cartera_formato', body: IA360_CARTERA_COPY.recordatorioFormato }); + return true; + } + return false; + } + + // PASO 1 — disparo del flujo (solo tema cartera; gate del goal). + if (state !== 'mapa_entregado' && ia360EsMensajeCartera(body)) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['cartera-quickwin'], + customFields: { + ia360_cartera_state: 'esperando_tabla', + ia360_cartera_dolor: body, + ia360_cartera_paso1_at: new Date().toISOString(), + }, + }); + await syncCarteraChampionsDeal({ + record, + targetStageName: CARTERA_STAGE_VALIDACION, + notes: `PASO 1 mapa de cartera: el contacto reportó saldos que no cuadran. Mensaje: ${body}`, + }).catch(e => console.error('[cartera] deal paso 1:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_cartera_paso1', body: IA360_CARTERA_COPY.paso1 }); + return true; + } + return false; + } catch (err) { + console.error('[cartera] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + step2: { + si_pregunta: 'Va la pregunta: si este mensaje te hubiera llegado sin conocer a Alek, ¿se entiende qué es IA360 y qué puedo y no puedo hacer como IA, o hay algo que te haría desconfiar? Dímelo con toda franqueza; para eso es esta prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_architectura:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_architectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_architectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + step2: { + si_pregunta: 'Gracias. ¿Cómo se siente recibir un mensaje así de una IA: natural, raro o invasivo? Lo que me digas se lo paso a Alek tal cual, sin suavizarlo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_feedback:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_feedback:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_feedback:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + step2: { + si_a_ver: 'Va. Pregúntame algo que Alek y tú hayan platicado o trabajado antes, y te digo qué tengo registrado. Tú pones la prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_memoria:si_a_ver', title: 'Sí, a ver' }, + { id: 'seq_beta_memoria:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_memoria:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + step2: { + pregunta: '¿Qué te contó la persona que nos presentó sobre lo que hace Alek, y qué te llamó la atención para aceptar la introducción? Con eso evitamos mandarte algo fuera de lugar.', + }, + draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_contexto:pregunta', title: 'Hazme una pregunta' }, + { id: 'seq_referido_contexto:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_contexto:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + step2: { + si_cuentame: 'Va la versión completa en corto: IA360 conecta WhatsApp, CRM, agenda y memoria de clientes para que el seguimiento no dependa de la memoria de nadie. ¿En tu operación dónde se cae más el seguimiento hoy: mensajes, CRM o agenda?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_oneliner:si_cuentame', title: 'Sí, cuéntame más' }, + { id: 'seq_referido_oneliner:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_oneliner:ahora_no', title: 'Por ahora no' }, + ], + }, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + step2: { + pregunta: 'Claro, pregunta con confianza: qué hace IA360, cómo trabaja Alek o qué implicaría la llamada. Te respondo aquí mismo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, + metaTemplateName: 'ia360_referido_apertura', + // El template de Meta trae botones de texto ("Sí, cuéntame"); su afirmativo + // mapea a la rama de horarios (el copy pide permiso para proponer llamada). + templateAliasOption: 'horarios', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_permiso_agenda:horarios', title: 'Proponme horarios' }, + { id: 'seq_referido_permiso_agenda:pregunta', title: 'Primero una pregunta' }, + { id: 'seq_referido_permiso_agenda:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + step2: { + si_pregunta: 'Gracias. ¿Qué tipo de clientes atiendes hoy y dónde los ves sufrir más: WhatsApp desordenado, CRM sin seguimiento o procesos repetidos a mano? Con eso mapeamos el fit.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, + metaTemplateName: 'ia360_aliado_mapa_colaboracion', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_mapa_colaboracion:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_mapa_colaboracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_mapa_colaboracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + step2: { + si_pregunta: 'Va: cuando un cliente tuyo ya necesita ordenar WhatsApp, CRM o seguimiento, ¿qué señales lo delatan primero? Con eso definimos juntos a quién sí presentarle IA360.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_criterios_fit:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_criterios_fit:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_criterios_fit:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + step2: { + si_comparte: 'Va el caso NDA-safe en corto: una empresa de servicios perdía seguimiento entre WhatsApp y su CRM; con IA360 cada conversación queda registrada, el pipeline se mueve solo y el dueño revisa su semana en un tablero. ¿Le haría sentido a alguno de tus clientes?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_caso_reventa:si_comparte', title: 'Sí, compártelo' }, + { id: 'seq_aliado_caso_reventa:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_caso_reventa:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + step2: { + si_cuento: 'Te leo. Cuéntame el avance, la fricción o el pendiente con el detalle que quieras; se lo dejo a Alek con contexto hoy mismo.', + todo_bien: 'Qué bueno. Le paso a Alek que todo va en orden. Cualquier cosa que surja, me escribes por aquí y se lo pongo enfrente.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_readout:si_cuento', title: 'Sí, te cuento' }, + { id: 'seq_cliente_readout:todo_bien', title: 'Todo va bien' }, + { id: 'seq_cliente_readout:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + step2: { + hay_tema: 'Cuéntame el tema con el detalle que quieras; se lo paso a Alek hoy mismo con prioridad para que no se quede atorado.', + todo_orden: 'Perfecto, me da gusto. Le confirmo a Alek que no hay pendientes de su lado. Aquí sigo si surge algo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_soporte:hay_tema', title: 'Sí, hay un tema' }, + { id: 'seq_cliente_soporte:todo_orden', title: 'Todo en orden' }, + { id: 'seq_cliente_soporte:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te escribo de su parte porque tú y Alek ya tienen un proyecto andando, y Alek quiere ubicar dónde estaría el siguiente paso con más impacto, sin empujarte nada fuera de tiempo. De estas áreas, ¿cuál te quita más tiempo hoy?`, + requiresLiveDeal: true, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cliente_expansion:whatsapp', title: 'WhatsApp y mensajes' }, + { id: 'seq_cliente_expansion:crm', title: 'CRM y clientes' }, + { id: 'seq_cliente_expansion:datos', title: 'Datos y reportes' }, + { id: 'seq_cliente_expansion:agenda', title: 'Agenda y citas' }, + { id: 'seq_cliente_expansion:seguimiento', title: 'Seguimiento de ventas' }, + { id: 'seq_cliente_expansion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte antes de mandarte una demo genérica: prefiere ubicar primero dónde habría valor real para tu operación. De estas áreas, ¿dónde sientes el cuello de botella que más mueve la aguja?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_sponsor_diagnostico:operacion', title: 'Operación' }, + { id: 'seq_sponsor_diagnostico:ventas', title: 'Ventas' }, + { id: 'seq_sponsor_diagnostico:datos', title: 'Datos y reportes' }, + { id: 'seq_sponsor_diagnostico:seguimiento', title: 'Seguimiento' }, + { id: 'seq_sponsor_diagnostico:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir fuga', + options: [ + { id: 'seq_sponsor_fuga_valor:tiempo', title: 'Tiempo perdido' }, + { id: 'seq_sponsor_fuga_valor:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_sponsor_fuga_valor:datos', title: 'Datos poco confiables' }, + { id: 'seq_sponsor_fuga_valor:decisiones', title: 'Decisiones lentas' }, + { id: 'seq_sponsor_fuga_valor:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + step2: { + si_manda: 'Va el caso en corto: una operación que dependía de WhatsApp y Excel perdía seguimiento y visibilidad; con IA360 los mensajes alimentan el CRM, el pipeline se mueve solo y la dirección revisa su semana en un tablero. Si quieres, Alek te aterriza el paralelo con tu operación en una llamada corta.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_sponsor_caso_ndasafe:si_manda', title: 'Sí, mándalo' }, + { id: 'seq_sponsor_caso_ndasafe:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_sponsor_caso_ndasafe:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja ayudando a equipos comerciales y casi siempre el problema aparece en uno de tres lugares. En tu equipo, ¿cuál duele más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_pipeline:leads', title: 'Leads que no llegan' }, + { id: 'seq_comercial_pipeline:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_comercial_pipeline:contexto', title: 'WhatsApp sin contexto' }, + { id: 'seq_comercial_pipeline:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_wa_crm:historial', title: 'Historial de clientes' }, + { id: 'seq_comercial_wa_crm:seguimiento', title: 'Seguimiento' }, + { id: 'seq_comercial_wa_crm:prioridad', title: 'Prioridad de leads' }, + { id: 'seq_comercial_wa_crm:datos', title: 'Datos para decidir' }, + { id: 'seq_comercial_wa_crm:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_motor_prospeccion:segmento', title: 'Segmento claro' }, + { id: 'seq_comercial_motor_prospeccion:mensaje', title: 'Mensaje repetible' }, + { id: 'seq_comercial_motor_prospeccion:seguimiento', title: 'Seguimiento medible' }, + { id: 'seq_comercial_motor_prospeccion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja con equipos de finanzas que terminan operando a mano porque no pueden confiar rápido en sus datos. En tu caso, ¿dónde está el mayor dolor hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cfo_control:cartera', title: 'Cartera' }, + { id: 'seq_cfo_control:comisiones', title: 'Comisiones' }, + { id: 'seq_cfo_control:reportes', title: 'Reportes' }, + { id: 'seq_cfo_control:conciliacion', title: 'Conciliación' }, + { id: 'seq_cfo_control:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + step2: { + respondo: 'Te leo. Cuéntame qué información te cuesta más tener confiable y a tiempo (cartera, cobranza, reportes), y se la paso a Alek aterrizada.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cfo_cartera_datos:respondo', title: 'Te respondo aquí' }, + { id: 'seq_cfo_cartera_datos:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_cfo_cartera_datos:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_cfo_comisiones:reglas', title: 'Reglas manuales' }, + { id: 'seq_cfo_comisiones:excepciones', title: 'Excepciones' }, + { id: 'seq_cfo_comisiones:datos', title: 'Datos que no cuadran' }, + { id: 'seq_cfo_comisiones:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + step2: { + mapa: 'Va el mapa corto: WhatsApp Cloud API → ForgeChat (bandeja y reglas) → n8n (orquestación) → CRM y memoria por contacto. Todo con permisos mínimos, trazabilidad de cada mensaje y aprobación humana antes de cualquier envío sensible. Si quieres el detalle técnico completo, Alek te lo manda directo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_arquitectura:mapa', title: 'Mándame el mapa' }, + { id: 'seq_tecnico_arquitectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_arquitectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada?`, + openerOptions: { + kind: 'list', + button: 'Elegir riesgo', + options: [ + { id: 'seq_tecnico_rollback:permisos', title: 'Permisos' }, + { id: 'seq_tecnico_rollback:datos', title: 'Datos' }, + { id: 'seq_tecnico_rollback:trazabilidad', title: 'Trazabilidad' }, + { id: 'seq_tecnico_rollback:reversibilidad', title: 'Reversibilidad' }, + { id: 'seq_tecnico_rollback:dependencia', title: 'Dependencia operativa' }, + { id: 'seq_tecnico_rollback:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + step2: { + respondo: 'Te leo. Dime qué condición tendría que cumplirse para que la prueba te parezca segura (permisos, alcance, datos, reversibilidad) y la registro tal cual para Alek.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_integracion:respondo', title: 'Te respondo aquí' }, + { id: 'seq_tecnico_integracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_integracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +// Openers v2: saludo con primer nombre (D9). Limpia el sufijo de QA y toma el +// primer token; si no hay nada usable devuelve el valor original. +function ia360FirstNameFrom(name) { + const raw = String(name || '').trim().replace(/\s+WhatsApp IA360$/i, '').trim(); + return raw.split(/\s+/).filter(Boolean)[0] || raw; +} + +// Openers v2: arma el objeto `interactive` de un opener desde sequence.openerOptions +// (kind 'buttons' ≤3 opciones, kind 'list' 4+). Sin header ni footer: el copy +// aprobado por Alek va fiel en body.text. Devuelve null si la secuencia no tiene +// openerOptions (esas siguen saliendo como texto plano). +function buildIa360OpenerInteractive({ sequence, bodyText }) { + const opts = sequence && sequence.openerOptions; + if (!opts || !Array.isArray(opts.options) || !opts.options.length) return null; + if (opts.kind === 'list') { + return { + type: 'list', + body: { text: bodyText }, + action: { + button: opts.button || 'Elegir', + sections: [{ + title: 'Opciones', + rows: opts.options.map(o => ({ id: o.id, title: o.title, ...(o.description ? { description: o.description } : {}) })), + }], + }, + }; + } + return { + type: 'button', + body: { text: bodyText }, + action: { + buttons: opts.options.slice(0, 3).map(o => ({ type: 'reply', reply: { id: o.id, title: o.title } })), + }, + }; +} + +// ── G-C: ruteo real de respuestas seq_* (openers v2) ───────────────────────── +// Un botón/fila `seq_:` del catálogo persona-first SIEMPRE +// recibe un siguiente paso real: paso 2 definido en el catálogo (`step2`), +// manejo semántico compartido (alek_directo / ahora_no / horarios) o acuse +// específico con eco de la elección + aviso al owner con la nextAction de la +// secuencia. Devuelve true si lo manejó; false SOLO para ids seq_* que no están +// en el catálogo (esos sí caen al fallback global, porque son inválidos). +async function handleIa360SequenceReply({ record, replyId, contact = null }) { + const m = /^seq_([a-z0-9_]+):([a-z0-9_]+)$/.exec(String(replyId || '').trim().toLowerCase()); + if (!m) return false; + const sequenceId = m[1]; + const optionKey = m[2]; + const found = findIa360SequenceFlow(sequenceId); + if (!found) return false; + const { sequence } = found; + const option = (sequence.openerOptions?.options || []) + .find(o => String(o.id).toLowerCase() === `seq_${sequenceId}:${optionKey}`); + if (!option) return false; + try { + const ctx = contact || await loadIa360ContactContext(record).catch(() => null); + const cf = ctx?.custom_fields || {}; + const contactName = ctx?.name || record.contact_name || record.contact_number; + const safeName = sanitizeIa360IntroName(contactName) || record.contact_number; + const nowIso = new Date().toISOString(); + + // Guard de estado (paridad con el router 100M): si la conversación ya avanzó + // a agenda/reunión/handoff humano, un botón seq_* de un opener viejo NO mueve + // el deal hacia atrás; responde continuidad y el owner se entera del tap. + const guard = await ia360HundredMAdvancedGuard(record); + if (guard.advanced) { + await enqueueIa360Text({ record, label: `ia360_seq_continuity_${sequenceId}`, body: guard.body }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_stale_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) tocó "${option.title}" de un opener viejo ("${sequence.label}"), pero su proceso ya va más adelante. No moví nada; le respondí con continuidad.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] stale notify:', e.message)); + return true; + } + + // Dedupe de doble tap del contacto: misma secuencia+opción ya registrada → + // continuidad corta, sin re-registro ni avisos duplicados al owner. + const prev = cf.ia360_seq_last_response || null; + if (prev && prev.sequence === sequenceId && prev.option === optionKey) { + await enqueueIa360Text({ + record, + label: `ia360_seq_dup_${sequenceId}`, + body: `Ya tengo registrada tu respuesta "${option.title}" y Alek ya tiene el contexto. Quedo al pendiente; cualquier cosa me escribes por aquí.`, + }); + return true; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-seq-respuesta', `seq-${sequenceId}`], + customFields: { + ia360_seq_last_response: { sequence: sequenceId, option: optionKey, title: option.title, at: nowIso }, + ia360_ultima_respuesta: option.title, + ultimo_cta_enviado: `ia360_seq_reply_${sequenceId}_${optionKey}`, + }, + }).catch(e => console.error('[ia360-seq] merge state:', e.message)); + + const notifyOwner = (detalle) => sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_reply_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) respondió "${option.title}" al opener "${sequence.label}". ${detalle}`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] notify owner:', e.message)); + + // 1) Salida directa con Alek. + if (optionKey === 'alek_directo') { + await enqueueIa360Text({ + record, + label: `ia360_seq_alek_directo_${sequenceId}`, + body: 'Perfecto, le aviso a Alek ahora mismo para que te escriba directo. Gracias por responder.', + }); + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió hablar directo con Alek.`, + }).catch(e => console.error('[ia360-seq] deal alek_directo:', e.message)); + await notifyOwner('Pidió que le escribas TÚ directo. Deal en "Requiere Alek".'); + return true; + } + + // 2) Cierre suave → nutrición. + if (optionKey === 'ahora_no') { + await enqueueIa360Text({ + record, + label: `ia360_seq_ahora_no_${sequenceId}`, + body: 'De acuerdo, no te insisto. Si más adelante quieres retomarlo, me escribes por aquí y seguimos donde lo dejamos.', + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: {}, + }).catch(e => console.error('[ia360-seq] tag nutricion:', e.message)); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: ahora no. Pasa a nutrición suave.`, + }).catch(e => console.error('[ia360-seq] deal ahora_no:', e.message)); + await notifyOwner('Respondió que ahora no; queda en nutrición suave.'); + return true; + } + + // 3) Agenda con permiso (referido_permiso_agenda:horarios). + if (optionKey === 'horarios') { + await enqueueIa360Interactive({ + record, + label: `ia360_seq_horarios_${sequenceId}`, + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + body: { text: 'Perfecto. ¿Qué ventana te acomoda mejor para la llamada con Alek?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió horarios. Deal a "Agenda en proceso".`, + }).catch(e => console.error('[ia360-seq] deal horarios:', e.message)); + await notifyOwner('Pidió horarios para una llamada contigo. Deal en "Agenda en proceso".'); + return true; + } + + // 4) Paso 2 definido en el catálogo. + const step2 = sequence.step2 && sequence.step2[optionKey]; + if (step2) { + await enqueueIa360Text({ + record, + label: `ia360_seq_step2_${sequenceId}_${optionKey}`, + body: step2, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Paso 2 de la secuencia enviado.`, + }).catch(e => console.error('[ia360-seq] deal step2:', e.message)); + await notifyOwner(`Le envié el paso 2 de la secuencia. Next action sugerida: ${sequence.nextAction}`); + return true; + } + + // 5) Sin paso 2 en el catálogo (temas de lista): acuse específico con eco de + // la elección + aviso al owner con la respuesta y la next action sugerida. + await enqueueIa360Text({ + record, + label: `ia360_seq_ack_${sequenceId}_${optionKey}`, + body: `Gracias, registré tu respuesta: "${option.title}". Le paso este contexto a Alek para que el siguiente paso vaya directo a eso, sin rodeos. Te escribe él con una propuesta concreta.`, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Acuse enviado; siguiente paso con Alek.`, + }).catch(e => console.error('[ia360-seq] deal ack:', e.message)); + await notifyOwner(`Next action sugerida: ${sequence.nextAction}`); + return true; + } catch (err) { + console.error('[ia360-seq] reply error:', err.message); + // Nunca mudo: acuse mínimo aunque el registro haya fallado. + await enqueueIa360Text({ + record, + label: 'ia360_seq_ack_error', + body: 'Recibí tu respuesta y ya se la pasé a Alek. Te escribe él en corto.', + }).catch(() => {}); + return true; + } +} + +// ── G-C: CTAs únicos — alias de botones de template (quick replies de texto) ── +// Los templates fríos (p. ej. ia360_referido_apertura = template 41, +// ia360_aliado_mapa_colaboracion = template 43) llegan con button.payload = +// TEXTO del botón, no un id estructurado, por lo que "Sí, cuéntame" era ambiguo +// entre Revenue OS y Referidos. Revenue OS se resuelve ANTES en el dispatch +// (handleRevenueOsButton, gateado por ia360_revenue_state); si no era suyo, este +// alias traduce el texto al id seq_* ÚNICO de la secuencia persona-first cuyo +// opener realmente se le envió al contacto (pf.sequence_candidate.id + pf.send). +const IA360_SEQ_ALIAS_NEGATIVE = new Set(['ahora no', 'por ahora no', 'no por ahora']); +const IA360_SEQ_ALIAS_HANDOFF = new Set(['que me escriba alek', 'hablar con alek']); +// Solo frases genuinamente afirmativas. Los títulos exactos del catálogo +// ("Proponme horarios", "Te respondo aquí", etc.) se resuelven por match de +// título, no por semántica: ponerlos aquí fabricaría elecciones equivocadas. +const IA360_SEQ_ALIAS_AFFIRMATIVE = new Set([ + 'sí, cuéntame', 'si, cuéntame', 'sí, cuentame', 'si, cuentame', + 'sí, cuéntame más', 'si, cuentame mas', + 'sí, pregúntame', 'si, preguntame', + 'sí, mándalo', 'si, mandalo', + 'sí, compártelo', 'si, compartelo', + 'sí, a ver', 'si, a ver', + 'sí, te cuento', 'si, te cuento', + 'sí, hay un tema', 'si, hay un tema', + 'me interesa', 'sí, me interesa', 'si, me interesa', +]); + +function resolveIa360TemplateButtonAlias({ replyId, contact }) { + const key = String(replyId || '').trim().toLowerCase(); + if (!key || key.startsWith('seq_')) return null; + const isNeg = IA360_SEQ_ALIAS_NEGATIVE.has(key); + const isHand = IA360_SEQ_ALIAS_HANDOFF.has(key); + const isAff = IA360_SEQ_ALIAS_AFFIRMATIVE.has(key); + if (!isNeg && !isHand && !isAff) return null; + const pf = contact?.custom_fields?.ia360_persona_first; + const seqId = pf?.sequence_candidate?.id; + if (!seqId || !pf?.send?.sent_at) return null; // solo si su opener realmente salió + const found = findIa360SequenceFlow(seqId); + if (!found) return null; + const opts = found.sequence.openerOptions?.options || []; + // 1) Match exacto por título visible del botón. + const byTitle = opts.find(o => String(o.title).trim().toLowerCase() === key); + if (byTitle) return String(byTitle.id).toLowerCase(); + // 2) Por semántica: negativo → ahora_no; handoff → alek_directo; afirmativo → + // la primera opción que no sea ninguna de las dos (el camino afirmativo). + const bySuffix = (suffix) => opts.find(o => String(o.id).toLowerCase().endsWith(`:${suffix}`)); + if (isNeg) { const o = bySuffix('ahora_no'); return o ? String(o.id).toLowerCase() : null; } + if (isHand) { const o = bySuffix('alek_directo'); return o ? String(o.id).toLowerCase() : null; } + // Afirmativo SOLO cuando es inequívoco: la secuencia declara su opción de + // template (templateAliasOption) o existe exactamente UNA opción no terminal. + // Con varias opciones posibles (listas de temas) NO se fabrica una elección: + // se devuelve null y el fallback global acusa recibo y avisa al owner. + if (found.sequence.templateAliasOption) { + const o = bySuffix(found.sequence.templateAliasOption); + if (o) return String(o.id).toLowerCase(); + } + const nonTerminal = opts.filter(o => { + const id = String(o.id).toLowerCase(); + return !id.endsWith(':ahora_no') && !id.endsWith(':alek_directo'); + }); + return nonTerminal.length === 1 ? String(nonTerminal[0].id).toLowerCase() : null; +} + +// ── G-C: anti-loop del router 100M ─────────────────────────────────────────── +// Nodos que en las pruebas reales generaron ciclos (doc 2026-06-10, chat_history +// 1068-1079 y 1135-1142): exploración, mecanismos, mapa y ejemplo. Una visita +// repetida ya no reenvía el bloque completo: responde una versión condensada con +// salidas terminales (agendar / llamada / más adelante). +const IA360_100M_LOOP_PRONE = new Set([ + 'explorando', + 'mecanismo-whatsapp-crm', + 'mecanismo-erp-bi', + 'mecanismo-agentic-followup', + 'mapa-30-60-90-solicitado', + 'ejemplo-solicitado', +]); +// Etapas donde la conversación ya avanzó a agenda/handoff humano: un botón 100M +// de un mensaje viejo NO debe reabrir la rama (guard de estado/versión). +const IA360_100M_ADVANCED_STAGES = new Set(['Agenda en proceso', 'Reunión agendada', 'Requiere Alek']); + +async function ia360HundredMAdvancedGuard(record) { + const out = { advanced: false, body: '', visited: {}, visitedOk: false }; + try { + const contact = await loadIa360ContactContext(record).catch(() => null); + const cf = contact?.custom_fields || {}; + if (contact) { + out.visited = (cf.ia360_100m_visited && typeof cf.ia360_100m_visited === 'object') ? cf.ia360_100m_visited : {}; + out.visitedOk = true; // lectura confiable: se puede escribir sin pisar el mapa + } + // Solo reuniones FUTURAS cuentan como "en curso": el cache crudo ia360_bookings + // conserva citas pasadas y atraparía al contacto para siempre. + const bookings = await loadIa360BookingsForList(record.contact_number).catch(() => []); + const hasBooking = Array.isArray(bookings) && bookings.length > 0; + let stageName = ''; + const deal = await getActiveNonTerminalIa360Deal(record).catch(() => null); + if (deal) stageName = deal.stage_name || ''; + if (hasBooking || IA360_100M_ADVANCED_STAGES.has(stageName)) { + out.advanced = true; + out.body = (hasBooking || stageName === 'Reunión agendada') + ? 'Vi tu respuesta, pero tu proceso ya va más adelante: tienes una reunión en curso con Alek. Sigo con eso para no regresarte al inicio. Si quieres mover la reunión o retomar otro tema, dímelo por aquí.' + : 'Vi tu respuesta a un mensaje anterior, pero tu proceso ya va más adelante: estamos en la parte de agenda con Alek. Sigo con eso para no darte vueltas; si quieres retomar otro tema, dímelo por aquí y lo vemos.'; + } + } catch (err) { + console.error('[ia360-100m] advanced guard:', err.message); + } + return out; +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + // Openers v2: saludo con primer nombre (D9) + quién hizo la introducción (D6). + const draftName = ia360FirstNameFrom(name); + const quienIntro = String(customFields.quien_intro || '').trim() || null; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name: draftName, quienIntro }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + ...(sequence.openerOptions && Array.isArray(sequence.openerOptions.options) + ? ['', `Opciones del mensaje (${sequence.openerOptions.kind === 'list' ? 'lista' : 'botones'}): ${sequence.openerOptions.options.map(o => o.title).join(' | ')}`] + : []), + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +// G-D: pipelines donde un deal vivo habilita la jugada de expansión (D7). +const IA360_EXPANSION_PIPELINES = ['IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión']; + +// G-D: señales reales del contacto para el ranker del selector de secuencias. +// Cada consulta tiene su propio try/catch (fail-open): si la DB falla, esa señal +// queda en null y el selector sale con el orden default — nunca mudo. +// OJO: ia360_memory_* tiene doble keying en contact_wa_number (a veces la línea +// del bot, a veces el número del contacto); la llave confiable es contact_number. +async function gatherIa360ContactSignals({ waNumber, contactNumber }) { + const signals = { liveDeal: null, quienIntro: null, lastFact: null, lastEvent: null, lastIncomingAt: null }; + try { + const { rows } = await pool.query( + `SELECT d.title, p.name AS pipeline_name, s.name AS stage_name + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.contact_wa_number = $1 AND d.contact_number = $2 AND d.status = 'open' + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [waNumber, contactNumber] + ); + if (rows.length) signals.liveDeal = { title: rows[0].title, pipelineName: rows[0].pipeline_name, stageName: rows[0].stage_name }; + } catch (e) { console.error('[ia360-rank] deal lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro, custom_fields->>'referido_por' AS referido_por + FROM coexistence.contacts + WHERE wa_number = $1 AND contact_number = $2 + LIMIT 1`, + [waNumber, contactNumber] + ); + const quienIntro = String(rows[0]?.quien_intro || '').trim(); + if (quienIntro) { + signals.quienIntro = quienIntro; + } else { + // referido_por guarda el NÚMERO de quien compartió el vCard. Solo cuenta + // como introductor si NO es el owner, ni el bot, ni el propio contacto; + // y solo con un nombre presentable (no citamos números pelones). + const referidoPor = normalizePhone(String(rows[0]?.referido_por || '').trim()); + if (referidoPor && referidoPor !== IA360_OWNER_NUMBER && referidoPor !== normalizePhone(waNumber) && referidoPor !== normalizePhone(contactNumber)) { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number = $1 AND contact_number = $2 LIMIT 1`, + [waNumber, referidoPor] + ); + const introName = String(introRows[0]?.name || introRows[0]?.profile_name || '').trim(); + if (introName) signals.quienIntro = introName; + } + } + } catch (e) { console.error('[ia360-rank] quien_intro lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT COALESCE(recurring_pain, preference, objection, role) AS texto, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + AND COALESCE(recurring_pain, preference, objection, role) IS NOT NULL + ORDER BY last_seen_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastFact = { text: rows[0].texto, lastSeenAt: rows[0].last_seen_at }; + } catch (e) { console.error('[ia360-rank] facts lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT summary, created_at + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastEvent = { summary: rows[0].summary, createdAt: rows[0].created_at }; + } catch (e) { console.error('[ia360-rank] events lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT MAX(created_at) AS last_in + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 AND direction = 'incoming'`, + [waNumber, contactNumber] + ); + if (rows[0]?.last_in) signals.lastIncomingAt = rows[0].last_in; + } catch (e) { console.error('[ia360-rank] chat_history lookup:', e.message); } + return signals; +} + +// G-D: ranker rule-based (SIN LLM). Solo REORDENA las secuencias de la persona +// elegida; cada razón cita una señal que existe en la base. Sin señales que +// matcheen secuencias de esta persona → orden de catálogo y cero razones +// inventadas (honestidad del ranker). +function rankIa360Sequences({ flow, signals }) { + const scores = new Map(); + const reasons = new Map(); + const bump = (id, pts, reason) => { + scores.set(id, (scores.get(id) || 0) + pts); + if (reason && !reasons.has(id)) reasons.set(id, reason); + }; + const s = signals || {}; + if (s.liveDeal) { + const dealReason = `Deal vivo «${s.liveDeal.title}» en ${s.liveDeal.pipelineName}`; + if (IA360_EXPANSION_PIPELINES.includes(s.liveDeal.pipelineName)) { + bump('cliente_expansion', 35, dealReason); + bump('cliente_readout', 20, dealReason); + bump('cliente_soporte', 10, dealReason); + } else { + bump('cliente_readout', 30, dealReason); + bump('cliente_soporte', 20, dealReason); + } + } + if (s.quienIntro) { + const introReason = `Te lo presentó ${s.quienIntro}`; + bump('referido_contexto', 30, introReason); + bump('referido_permiso_agenda', 15, introReason); + bump('referido_oneliner', 10, introReason); + } + const memorySignal = s.lastEvent || s.lastFact; + if (memorySignal) { + // 40 y no más: "Sugerida: Memoria registrada: " + frag debe caber en los + // 72 chars de la description de Meta sin perder el final de la razón. + const frag = compactForWhatsApp(s.lastEvent ? s.lastEvent.summary : s.lastFact.text, 40); + const memReason = `Memoria registrada: ${frag}`; + bump('beta_memoria', 15, memReason); + bump('cliente_readout', 10, memReason); + } + const ordered = (flow.sequences || []) + .map((seq, idx) => ({ seq, idx, score: scores.get(seq.id) || 0 })) + .sort((a, b) => (b.score - a.score) || (a.idx - b.idx)); + const ranked = ordered.length > 0 && ordered[0].score > 0; + return { + ordered: ordered.map(o => o.seq), + suggestedId: ranked ? ordered[0].seq.id : null, + reasonFor: (id) => reasons.get(id) || null, + ranked, + }; +} + +// G-D: resumen de 2 líneas del contacto para el cuerpo de la tarjeta. Solo +// afirma lo que existe; sin señales devuelve una sola línea honesta. +function buildIa360ContactSummaryLines(signals) { + const s = signals || {}; + const fmtDate = (d) => { + try { return new Date(d).toISOString().slice(0, 10); } catch { return ''; } + }; + if (!s.liveDeal && !s.quienIntro && !s.lastFact && !s.lastEvent) { + return ['Aún no tengo señales registradas de este contacto (sin deal, sin memoria, sin introductor).']; + } + // Tope de 180: título/pipeline/etapa vienen de la base sin límite y el body + // del interactive de Meta admite 1024 como máximo — si se excede, la tarjeta + // se vuelve muda. Acotado aquí, el body completo queda siempre < 1024. + const line1 = s.liveDeal + ? compactForWhatsApp(`Deal vivo: «${s.liveDeal.title}» — ${s.liveDeal.pipelineName}${s.liveDeal.stageName ? ` / ${s.liveDeal.stageName}` : ''}.`, 180) + : 'Sin deal vivo registrado.'; + let line2; + if (s.lastEvent) { + const fecha = fmtDate(s.lastEvent.createdAt); + line2 = `Último evento${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastEvent.summary, 120)}`; + } else if (s.lastFact) { + const fecha = fmtDate(s.lastFact.lastSeenAt); + line2 = `Memoria${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastFact.text, 120)}`; + } else if (s.quienIntro) { + line2 = `Lo presentó: ${s.quienIntro}.`; + } else if (s.lastIncomingAt) { + line2 = `Última interacción: ${fmtDate(s.lastIncomingAt)}.`; + } else { + line2 = 'Sin memoria registrada todavía.'; + } + return [line1, line2]; +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + // G-D: ranker rule-based sobre señales reales — la sugerida primero con el + // porqué en su descripción; sin señales → orden de catálogo sin razones. + const signals = await gatherIa360ContactSignals({ waNumber: record.wa_number, contactNumber: targetContact }); + const ranking = rankIa360Sequences({ flow, signals }); + const summaryLines = buildIa360ContactSummaryLines(signals); + const bodyText = [ + `Alek, ${name} quedó como ${flow.personaContext}.`, + ...summaryLines, + 'Elige una secuencia. Sigo en dry-run: no enviaré nada al contacto.', + ].join('\n'); + const suggestedReason = ranking.suggestedId ? ranking.reasonFor(ranking.suggestedId) : null; + // G-D: el ranking queda auditable en custom_fields (orden, sugerida, razón, + // resumen) — best-effort, no bloquea el envío de la tarjeta. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + customFields: { + ia360_selector_ranking: { + at: new Date().toISOString(), + persona: flowKey, + ranked: ranking.ranked, + suggested: ranking.suggestedId, + reason: suggestedReason, + order: ranking.ordered.map(seq => seq.id), + summary: summaryLines, + }, + }, + }).catch(e => console.error('[ia360-rank] persist ranking:', e.message)); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: ranking.ranked + ? `IA360: secuencias ${name} — sugerida: ${ranking.suggestedId} (${suggestedReason})` + : `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { text: bodyText }, + footer: { + text: ranking.ranked + ? 'Sugerida primero por señales; aprobación antes de envío' + : 'Persona antes de secuencia; aprobación antes de envío', + }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: ranking.ordered.map(seq => { + const reason = ranking.suggestedId === seq.id ? ranking.reasonFor(seq.id) : null; + return { + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(reason ? `Sugerida: ${reason}` : seq.goal, 72), + }; + }), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { + text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, + }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows: [ + { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ], + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // G-C: dedupe de doble tap. Si esta misma secuencia ya fue aprobada y su envío + // ya salió SIN fallar, un segundo tap de la tarjeta NO debe generar otro egress. + // Un envío fallido NO bloquea: el owner puede reintentar con la misma tarjeta. + if (pf.approval?.status === 'approved' && pf.send?.sent_at && String(pf.send.send_status || '').toLowerCase() !== 'failed' && !pf.send.error) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_dup', + body: `Ese opener ("${sequence.label}") ya se había enviado a ${name} (${pf.send.sent_at}). Detecté un doble tap y no envié nada nuevo.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // GUARDIA cliente_expansion (D7): la secuencia presupone un proyecto andando. + // Solo dispara si el contacto tiene un deal vivo (status='open') en P2 (IA360 + // WhatsApp Revenue Pipeline) o P7 (Champions). Sin deal vivo → bloquear con aviso. + // G-C: con try/catch — si la consulta falla, el owner se entera (nunca mudo) y + // NO se envía nada (fail-closed). + if (sequence.requiresLiveDeal) { + let liveRows; + try { + ({ rows: liveRows } = await pool.query( + `SELECT 1 + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') + AND d.contact_wa_number = $1 + AND d.contact_number = $2 + AND d.status = 'open' + LIMIT 1`, + [record.wa_number, targetContact] + )); + } catch (liveErr) { + console.error('[ia360-approve] live deal check failed:', liveErr.message); + return deny('live_deal_check_failed', `No pude verificar si ${name} tiene un proyecto activo (error de base de datos). Por seguridad no envié nada; reintenta en un momento.`); + } + if (!liveRows.length) { + return deny('no_live_deal', `${name} no tiene un proyecto activo (deal vivo en P2/P7). La secuencia ${sequence.id} solo aplica a clientes con proyecto en curso; elige otra secuencia. No envié nada.`); + } + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); + // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + // Openers v2: dentro de ventana el opener sale como interactive (botones/lista) + // con el copy aprobado en el readout; secuencias sin openerOptions siguen en texto. + const openerInteractive = buildIa360OpenerInteractive({ sequence, bodyText: pf.sequence_candidate.draft }); + let sent; + let handlerFor; + if (openerInteractive) { + sent = await enqueueIa360Interactive({ + record: targetRecord, + label: openerLabel, + messageBody: `IA360 opener: ${sequence.label}`, + interactive: openerInteractive, + dedupSuffix: `:opener:${targetContact}`, + }); + handlerFor = `${record.message_id}:opener:${targetContact}`; + } else { + sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + handlerFor = `${record.message_id}:direct:${targetContact}`; + } + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(handlerFor); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + // G-C: un opener nuevo abre un ciclo nuevo — la respuesta del ciclo anterior + // no debe activar el dedupe del router seq_* (el contacto debe poder volver + // a elegir la misma opción y recibir su paso 2). + ia360_seq_last_response: null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── Expediente del owner: "qué sabes de " ────────────────── +// Comando read-only del owner: arma un expediente con los facts y eventos de +// coexistence.ia360_memory_* para un contacto, resuelto por número o por +// nombre (tolerante a acentos y a typos simples tipo Emmanuel/Emanuel). +// Egress SOLO vía sendIa360DirectText; nunca escribe memoria y SIEMPRE +// responde algo (sin expediente / candidatos / error), nunca queda mudo. +const IA360_BOT_WA_NUMBER = '5213321594582'; // número del bot: jamás es contacto + +function parseIa360OwnerMemoryQuery(body) { + const text = String(body || '').trim(); + const m = text.match(/^¿?\s*(?:qu[eé]|qui[eé]n)\s+sabes\s+(?:de\s+la|de\s+el|del|de|sobre)\s+(.+?)\s*\?*$/i); + if (!m) return null; + const q = m[1].trim(); + return q || null; +} + +// Normaliza para comparar nombres: minúsculas, sin acentos y con letras +// repetidas colapsadas ("Emmanuel" y "Emanuel" → "emanuel"). +function ia360NormalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +async function resolveIa360MemoryTarget(query) { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + // Número directo: 10 dígitos MX → prefijo 521 (formato ForgeChat). + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const { rows } = await pool.query( + `SELECT DISTINCT contact_number, contact_name FROM ( + SELECT contact_number, contact_name + FROM coexistence.ia360_memory_events + WHERE contact_name IS NOT NULL AND contact_number IS NOT NULL + UNION ALL + SELECT contact_number, COALESCE(name, profile_name) AS contact_name + FROM coexistence.contacts + WHERE COALESCE(name, profile_name) IS NOT NULL AND contact_number IS NOT NULL + ) t + WHERE contact_number <> $1`, + [IA360_BOT_WA_NUMBER] + ); + const needle = ia360NormalizeNameForMatch(query); + if (!needle) return { kind: 'none', candidates: [] }; + const byNumber = new Map(); + for (const r of rows) { + if (!ia360NormalizeNameForMatch(r.contact_name).includes(needle)) continue; + if (!byNumber.has(r.contact_number)) byNumber.set(r.contact_number, r); + } + const candidates = [...byNumber.values()]; + if (!candidates.length) return { kind: 'none', candidates: [] }; + if (candidates.length > 1) return { kind: 'ambiguous', candidates }; + return { kind: 'name', candidates }; +} + +async function buildIa360ContactDossier(contactNumber) { + const num = normalizePhone(contactNumber); + const { rows: factRows } = await pool.query( + `SELECT project_name, persona, role, account_name, preference, objection, + recurring_pain, affected_process, missing_metric + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + ORDER BY last_seen_at DESC, id DESC`, + [num] + ); + const { rows: eventRows } = await pool.query( + `SELECT contact_name, area, signal_type, summary + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC, id DESC + LIMIT 12`, + [num] + ); + if (!factRows.length && !eventRows.length) return null; + + const name = eventRows.find(e => e.contact_name)?.contact_name || null; + const header = [`Expediente IA360: ${name || 'contacto'} (${num})`]; + const meta = []; + const accountRow = factRows.find(f => f.account_name); + const projectRow = factRows.find(f => f.project_name); + const personaRow = factRows.find(f => f.persona); + if (accountRow) meta.push(`Cuenta: ${accountRow.account_name}`); + if (projectRow) meta.push(`Proyecto: ${projectRow.project_name}`); + if (personaRow) meta.push(`Persona: ${personaRow.persona}`); + if (meta.length) header.push(meta.join(' · ')); + + // Los facts viven duplicados por el doble keying de contact_wa_number + // (monolito vs lookup v2): dedupe por contenido, no por fila. + const factLines = []; + const seenFacts = new Set(); + for (const f of factRows) { + for (const field of ['preference', 'objection', 'recurring_pain', 'affected_process', 'missing_metric']) { + const val = String(f[field] || '').trim(); + if (!val) continue; + const key = `${field}:${val}`; + if (seenFacts.has(key)) continue; + seenFacts.add(key); + factLines.push(`- ${val.length > 300 ? `${val.slice(0, 297)}...` : val}`); + } + } + const eventLines = []; + const seenEvents = new Set(); + for (const e of eventRows) { + const val = String(e.summary || '').trim(); + if (!val) continue; + const key = `${e.area}|${e.signal_type}|${val}`; + if (seenEvents.has(key)) continue; + seenEvents.add(key); + eventLines.push(`- [${e.area}/${e.signal_type}] ${val.length > 220 ? `${val.slice(0, 217)}...` : val}`); + } + + const lines = [...header, '']; + if (factLines.length) lines.push(`Facts (${factLines.length}):`, ...factLines, ''); + if (eventLines.length) lines.push(`Eventos recientes (${eventLines.length}):`, ...eventLines); + let body = lines.join('\n').trim(); + // Límite duro de WhatsApp: 4096 chars por texto. + if (body.length > 3900) body = `${body.slice(0, 3880)}\n... (recortado)`; + return body; +} + +async function handleIa360OwnerMemoryQuery({ record, query }) { + let body; + try { + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + body = `Sin expediente: no encontré facts ni eventos para "${query}". Revisa el nombre o mándame el número completo.`; + } else if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + body = `Encontré varios contactos que coinciden con "${query}". ¿De cuál quieres el expediente?\n${list}\n\nMándame "qué sabes de " para verlo.`; + } else { + const dossier = await buildIa360ContactDossier(target.candidates[0].contact_number); + body = dossier + || `Sin expediente: el contacto ${target.candidates[0].contact_number} no tiene facts ni eventos guardados todavía.`; + } + } catch (err) { + console.error('[ia360-expediente] dossier error:', err.message); + body = `No pude leer el expediente de "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); +} + +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // ── G-C: ruteo de respuestas a openers v2 (ids seq_* y alias de template) ── + // Va DESPUÉS de Revenue OS (que resuelve su propio "Sí, cuéntame" gateado por + // estado) y ANTES del embudo 100M. Un id seq_* del catálogo NUNCA cae al + // fallback global. + if (replyId && replyId.startsWith('seq_')) { + if (await handleIa360SequenceReply({ record, replyId })) return; + } else if (replyId || answer) { + const aliasKey = String(replyId || answer || '').trim().toLowerCase(); + if (IA360_SEQ_ALIAS_NEGATIVE.has(aliasKey) || IA360_SEQ_ALIAS_HANDOFF.has(aliasKey) || IA360_SEQ_ALIAS_AFFIRMATIVE.has(aliasKey)) { + try { + const aliasContact = await loadIa360ContactContext(record).catch(() => null); + const aliased = resolveIa360TemplateButtonAlias({ replyId: aliasKey, contact: aliasContact }); + if (aliased && await handleIa360SequenceReply({ record, replyId: aliased, contact: aliasContact })) return; + } catch (aliasErr) { + console.error('[ia360-seq] alias error:', aliasErr.message); + } + } + } + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + // G-C anti-loop: "No prioritario" ya NO ofrece "Aplicarlo" (reabría la rama + // comercial); las salidas son nutrición ("Más adelante") o baja. + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + // ── G-C: anti-loop del router 100M ────────────────────────────────────── + // 'baja' (optout) SIEMPRE pasa: la salida del contacto no se bloquea nunca. + if (flow100m.tag !== 'no-contactar') { + try { + const guard = await ia360HundredMAdvancedGuard(record); + // Guard de estado/versión: la conversación ya avanzó a agenda/reunión/ + // handoff humano → un botón de un mensaje viejo NO reabre la rama. + if (guard.advanced) { + await enqueueIa360Text({ record, label: 'ia360_100m_continuity', body: guard.body }); + return; + } + // Nodo loop-prone repetido → versión condensada con salidas terminales, + // no el bloque completo otra vez. Si la lectura del contacto falló, NO se + // escribe el mapa de visitas (se pisaría con un objeto vacío). + const visited = guard.visited || {}; + if (guard.visitedOk && IA360_100M_LOOP_PRONE.has(flow100m.tag)) { + if (visited[flow100m.tag]) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: (Number(visited[flow100m.tag]) || 0) + 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + await enqueueIa360Interactive({ + record, + label: 'ia360_100m_condensed', + messageBody: `IA360 100M: ${flow100m.title} (resumen)`, + interactive: { + type: 'button', + body: { text: `Eso ya lo vimos: ${flow100m.title}. Para no darte vueltas con lo mismo, mejor dime cómo cerramos: ¿agendamos una llamada corta con Alek o lo dejamos para más adelante?` }, + footer: { text: 'IA360 · sin vueltas' }, + action: { + buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada con Alek' } }, + { type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }, + ], + }, + }, + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + } + } catch (guardErr) { + console.error('[ia360-100m] guard error:', guardErr.message); + } + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + }, + }); + return; + } + + // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── + // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo o + // malformado). Los ids seq_* del catálogo y los quick replies de template con + // estado persona-first ya se rutean arriba (handleIa360SequenceReply + alias); + // aquí solo cae lo verdaderamente desconocido. El contacto siempre recibe + // acuse y el owner se entera. try/catch terminal: nunca tumba el webhook. + try { + const fallbackId = replyId || answer || '(sin id)'; + console.warn('[ia360-fallback] unhandled interactive reply contact=%s id=%s body=%s', record.contact_number || '-', fallbackId, String(record.message_body || '').slice(0, 80)); + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_ack', + body: `Recibí tu respuesta "${String(record.message_body || fallbackId).slice(0, 60)}", pero aún no tengo una acción conectada para ese botón (${fallbackId}). No hice ningún cambio.`, + }); + return; + } + await enqueueIa360Text({ + record, + label: 'ia360_interactive_fallback', + body: 'Recibí tu respuesta y la estoy ubicando para darte una respuesta útil. Si es urgente, Alek también puede escribirte directo.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_notice', + body: `Alek, ${record.contact_name || record.contact_number} (${record.contact_number}) respondió "${String(record.message_body || fallbackId).slice(0, 60)}" (id: ${fallbackId}) y no tengo un manejador para esa opción. Le acusé recibo; revisa si quieres tomarlo tú.`, + targetContact: record.contact_number, + ownerBudget: true, + }); + } catch (fbErr) { + console.error('[ia360-fallback] interactive fallback error:', fbErr.message); + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── EXPEDIENTE: comando del owner "qué sabes de " ── + // Mismo patrón que "idea:": va ANTES del canary Brain v2 (el owner + // está en la allowlist y el canary haría continue). Read-only sobre + // ia360_memory_facts/events; responde SIEMPRE (nunca queda mudo). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const memQuery = parseIa360OwnerMemoryQuery(record.message_body); + if (memQuery) { + await handleIa360OwnerMemoryQuery({ record, query: memQuery }) + .catch(e => console.error('[ia360-expediente] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + // G-WIN cartera: el bot no lee imágenes. Si el contacto está a media + // captura de tabla (esperando_tabla) y manda foto/archivo, pedimos la + // versión en texto y no seguimos el embudo para este record. + if (await handleCarteraMediaInbound(record)) { + continue; + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // G-WIN "Mapa de cartera" (P7 Champions) — mismo contrato que Revenue OS: + // gateado por persona+tema+estado; si actúa, CORTA el embudo para que el + // agente genérico no responda encima (guardrail: sin pitch, sin agenda). + const carteraHandled = revHandled + ? false + : await handleCarteraFreeText(record).catch(e => { console.error('[cartera] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled && !carteraHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +// G-WIN cartera — vista previa del flujo completo al WhatsApp del OWNER (nunca +// a un contacto): los mensajes de los 3 pasos con datos de ejemplo, en UN solo +// texto, para aprobación de copy. Egress único: sendIa360DirectText → +// messageSender. Auth = X-IA360-Directive-Secret (patrón de los endpoints +// internos). Idempotencia: el caller decide cuándo (una sola vez por sesión). +router.post('/internal/ia360-cartera/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const ejemploRows = [ + { cuenta: 'Transportes del Bajío', saldo_portal: '$1,250,000.00', saldo_correcto: '$980,000.00', fecha_corte: '31/05/2026', responsable: 'Laura' }, + { cuenta: 'Logística Occidente', saldo_portal: '$430,500.00', saldo_correcto: '$512,300.00', fecha_corte: '31/05/2026', responsable: 'Marco' }, + ]; + const mapaEjemplo = buildCarteraMapa(ejemploRows); + const preview = [ + 'IA360 · PREVIEW flujo "Mapa de cartera" (P7 Champions) — para tu aprobación. Nada de esto se envió a Andrés.', + '', + '── PASO 1 · El contacto reporta saldos que no cuadran. El bot responde: ──', + '', + IA360_CARTERA_COPY.paso1, + '', + '── Si manda foto o archivo en lugar de texto: ──', + '', + IA360_CARTERA_COPY.pideTexto, + '', + '── PASO 2 · El contacto pega la tabla. El bot responde (datos de ejemplo): ──', + '', + mapaEjemplo.texto, + '', + '── PASO 3 · Tú recibes este readout y el deal avanza a "Quick win entregado": ──', + '', + 'IA360 · Quick win cartera — (contacto) (521***XX)', + '', + 'El contacto entregó su tabla de cartera (2 cuentas) y le devolví el mapa estructurado.', + `- Cuentas con descuadre: ${mapaEjemplo.cuentasConDescuadre} · Diferencia acumulada: ${carteraFormatoMonto(mapaEjemplo.diferenciaTotal)}`, + '- Deal: «IA360 · (contacto) · Quick win cartera» → Quick win entregado (P7 Champions).', + '- Mapa encolado a ia360_docs_sync (destino AlekContenido).', + ].join('\n'); + const record = { + wa_number: normalizePhone(req.body?.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'), + contact_number: IA360_OWNER_NUMBER, + message_id: `cartera_preview:${Date.now()}`, + message_type: 'cartera_preview', + message_body: '', + }; + const sent = await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_cartera_preview_owner', + body: preview, + }); + return res.status(sent ? 200 : 502).json({ ok: !!sent, schema: 'ia360_cartera_preview.v1', chars: preview.length }); + } catch (err) { + console.error('[cartera] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// OPENERS V2 — vista previa de un opener al WhatsApp del OWNER (nunca a un +// contacto). Renderiza el draft v2 (primer nombre + quien_intro opcional) y el +// interactive (botones/lista) tal como lo vería el contacto. Único egress: +// sendOwnerInteractive / sendIa360DirectText -> messageSender. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). +router.post('/internal/ia360-openers/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const sequenceId = String(b.sequence_id || '').trim().toLowerCase(); + const found = findIa360SequenceFlow(sequenceId); + if (!found) return res.status(422).json({ ok: false, error: 'unknown_sequence', sequence_id: sequenceId }); + const { sequence } = found; + const sampleName = ia360FirstNameFrom(String(b.name || 'Alek').trim() || 'Alek'); + const quienIntro = String(b.quien_intro || '').trim() || null; + const bodyText = typeof sequence.draft === 'function' ? sequence.draft({ name: sampleName, quienIntro }) : String(sequence.draft || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const synthetic = { + wa_number: waNumber, + contact_number: IA360_OWNER_NUMBER, + message_id: `opener-preview-${sequenceId}-${Date.now()}`, + message_type: 'text', + direction: 'incoming', + }; + const interactive = buildIa360OpenerInteractive({ sequence, bodyText }); + let sent; + if (interactive) { + // ownerBudget=false: la preview es una petición explícita del owner; no debe + // caer en el presupuesto anti-spam de notificaciones. + sent = await sendOwnerInteractive({ + record: synthetic, + label: `ia360_opener_preview_${sequenceId}`, + messageBody: `IA360 preview opener ${sequenceId}`, + interactive, + }); + } else { + sent = await sendIa360DirectText({ + record: synthetic, + toNumber: IA360_OWNER_NUMBER, + label: `ia360_opener_preview_${sequenceId}`, + body: bodyText, + }); + } + return res.status(sent ? 200 : 502).json({ + ok: Boolean(sent), + schema: 'ia360_opener_preview.v1', + sequence_id: sequenceId, + kind: interactive ? (interactive.type === 'list' ? 'list' : 'buttons') : 'text', + body_preview: bodyText, + }); + } catch (err) { + console.error('[ia360-openers] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-gd-20260610T192115Z b/backend/src/routes/webhook.js.bak-pre-gd-20260610T192115Z new file mode 100644 index 0000000..16d76fa --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-gd-20260610T192115Z @@ -0,0 +1,7431 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +// G-C: el nombre del introductor viene de push name / vCard (texto controlado por +// el remitente). Se sanitiza antes de persistir: sin caracteres de control ni +// saltos de línea, sin llaves de placeholder, espacios colapsados y tope de 60 +// caracteres. Devuelve null si no queda nada usable. +function sanitizeIa360IntroName(raw) { + const clean = String(raw || '') + .replace(/[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ' ') + .replace(/[{}]/g, '') + .replace(/\s+/g, ' ') + .trim(); + // Corte por code points (no por unidades UTF-16): un emoji en la frontera de + // los 60 caracteres no deja un surrogate suelto que rompa el jsonb al persistir. + const capped = Array.from(clean).slice(0, 60).join('').trim(); + if (!capped) return null; + if (!/[\p{L}]/u.test(capped)) return null; // sin letras (solo dígitos/símbolos) no sirve como nombre + return capped; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + // quien_intro (D6): si el vCard lo comparte un CONTACTO (no el owner), esa + // persona es quien hizo la introducción. Se guarda el NOMBRE para que el + // opener referido_contexto pueda decir "nos presentó X". Si lo manda el owner, + // el dato queda pendiente (el placeholder {{quien_intro}} bloquea el copy). + // G-C: sanitizado (push name inyectable), sin auto-introducción (vCard propio) + // y sin pisar un quien_intro ya capturado. + let quienIntro = null; + const sharerIsSelf = normalizePhone(record.contact_number || '') === normalizePhone(shared.contactNumber || ''); + if (record.contact_number && !sharerIsSelf && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + try { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(record.contact_number)] + ); + quienIntro = sanitizeIa360IntroName(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || ''); + if (quienIntro) { + const { rows: existingRows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(shared.contactNumber)] + ); + if (String(existingRows[0]?.quien_intro || '').trim()) quienIntro = null; // ya hay introductor registrado: no pisar + } + } catch (e) { + console.error('[ia360-vcard] quien_intro lookup:', e.message); + quienIntro = null; + } + } + const customFields = { + ...(quienIntro ? { quien_intro: quienIntro } : {}), + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + step2: { + si_pregunta: 'Va la pregunta: si este mensaje te hubiera llegado sin conocer a Alek, ¿se entiende qué es IA360 y qué puedo y no puedo hacer como IA, o hay algo que te haría desconfiar? Dímelo con toda franqueza; para eso es esta prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_architectura:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_architectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_architectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + step2: { + si_pregunta: 'Gracias. ¿Cómo se siente recibir un mensaje así de una IA: natural, raro o invasivo? Lo que me digas se lo paso a Alek tal cual, sin suavizarlo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_feedback:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_feedback:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_feedback:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + step2: { + si_a_ver: 'Va. Pregúntame algo que Alek y tú hayan platicado o trabajado antes, y te digo qué tengo registrado. Tú pones la prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_memoria:si_a_ver', title: 'Sí, a ver' }, + { id: 'seq_beta_memoria:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_memoria:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + step2: { + pregunta: '¿Qué te contó la persona que nos presentó sobre lo que hace Alek, y qué te llamó la atención para aceptar la introducción? Con eso evitamos mandarte algo fuera de lugar.', + }, + draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_contexto:pregunta', title: 'Hazme una pregunta' }, + { id: 'seq_referido_contexto:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_contexto:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + step2: { + si_cuentame: 'Va la versión completa en corto: IA360 conecta WhatsApp, CRM, agenda y memoria de clientes para que el seguimiento no dependa de la memoria de nadie. ¿En tu operación dónde se cae más el seguimiento hoy: mensajes, CRM o agenda?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_oneliner:si_cuentame', title: 'Sí, cuéntame más' }, + { id: 'seq_referido_oneliner:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_oneliner:ahora_no', title: 'Por ahora no' }, + ], + }, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + step2: { + pregunta: 'Claro, pregunta con confianza: qué hace IA360, cómo trabaja Alek o qué implicaría la llamada. Te respondo aquí mismo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, + metaTemplateName: 'ia360_referido_apertura', + // El template de Meta trae botones de texto ("Sí, cuéntame"); su afirmativo + // mapea a la rama de horarios (el copy pide permiso para proponer llamada). + templateAliasOption: 'horarios', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_permiso_agenda:horarios', title: 'Proponme horarios' }, + { id: 'seq_referido_permiso_agenda:pregunta', title: 'Primero una pregunta' }, + { id: 'seq_referido_permiso_agenda:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + step2: { + si_pregunta: 'Gracias. ¿Qué tipo de clientes atiendes hoy y dónde los ves sufrir más: WhatsApp desordenado, CRM sin seguimiento o procesos repetidos a mano? Con eso mapeamos el fit.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, + metaTemplateName: 'ia360_aliado_mapa_colaboracion', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_mapa_colaboracion:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_mapa_colaboracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_mapa_colaboracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + step2: { + si_pregunta: 'Va: cuando un cliente tuyo ya necesita ordenar WhatsApp, CRM o seguimiento, ¿qué señales lo delatan primero? Con eso definimos juntos a quién sí presentarle IA360.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_criterios_fit:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_criterios_fit:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_criterios_fit:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + step2: { + si_comparte: 'Va el caso NDA-safe en corto: una empresa de servicios perdía seguimiento entre WhatsApp y su CRM; con IA360 cada conversación queda registrada, el pipeline se mueve solo y el dueño revisa su semana en un tablero. ¿Le haría sentido a alguno de tus clientes?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_caso_reventa:si_comparte', title: 'Sí, compártelo' }, + { id: 'seq_aliado_caso_reventa:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_caso_reventa:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + step2: { + si_cuento: 'Te leo. Cuéntame el avance, la fricción o el pendiente con el detalle que quieras; se lo dejo a Alek con contexto hoy mismo.', + todo_bien: 'Qué bueno. Le paso a Alek que todo va en orden. Cualquier cosa que surja, me escribes por aquí y se lo pongo enfrente.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_readout:si_cuento', title: 'Sí, te cuento' }, + { id: 'seq_cliente_readout:todo_bien', title: 'Todo va bien' }, + { id: 'seq_cliente_readout:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + step2: { + hay_tema: 'Cuéntame el tema con el detalle que quieras; se lo paso a Alek hoy mismo con prioridad para que no se quede atorado.', + todo_orden: 'Perfecto, me da gusto. Le confirmo a Alek que no hay pendientes de su lado. Aquí sigo si surge algo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_soporte:hay_tema', title: 'Sí, hay un tema' }, + { id: 'seq_cliente_soporte:todo_orden', title: 'Todo en orden' }, + { id: 'seq_cliente_soporte:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te escribo de su parte porque tú y Alek ya tienen un proyecto andando, y Alek quiere ubicar dónde estaría el siguiente paso con más impacto, sin empujarte nada fuera de tiempo. De estas áreas, ¿cuál te quita más tiempo hoy?`, + requiresLiveDeal: true, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cliente_expansion:whatsapp', title: 'WhatsApp y mensajes' }, + { id: 'seq_cliente_expansion:crm', title: 'CRM y clientes' }, + { id: 'seq_cliente_expansion:datos', title: 'Datos y reportes' }, + { id: 'seq_cliente_expansion:agenda', title: 'Agenda y citas' }, + { id: 'seq_cliente_expansion:seguimiento', title: 'Seguimiento de ventas' }, + { id: 'seq_cliente_expansion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte antes de mandarte una demo genérica: prefiere ubicar primero dónde habría valor real para tu operación. De estas áreas, ¿dónde sientes el cuello de botella que más mueve la aguja?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_sponsor_diagnostico:operacion', title: 'Operación' }, + { id: 'seq_sponsor_diagnostico:ventas', title: 'Ventas' }, + { id: 'seq_sponsor_diagnostico:datos', title: 'Datos y reportes' }, + { id: 'seq_sponsor_diagnostico:seguimiento', title: 'Seguimiento' }, + { id: 'seq_sponsor_diagnostico:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir fuga', + options: [ + { id: 'seq_sponsor_fuga_valor:tiempo', title: 'Tiempo perdido' }, + { id: 'seq_sponsor_fuga_valor:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_sponsor_fuga_valor:datos', title: 'Datos poco confiables' }, + { id: 'seq_sponsor_fuga_valor:decisiones', title: 'Decisiones lentas' }, + { id: 'seq_sponsor_fuga_valor:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + step2: { + si_manda: 'Va el caso en corto: una operación que dependía de WhatsApp y Excel perdía seguimiento y visibilidad; con IA360 los mensajes alimentan el CRM, el pipeline se mueve solo y la dirección revisa su semana en un tablero. Si quieres, Alek te aterriza el paralelo con tu operación en una llamada corta.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_sponsor_caso_ndasafe:si_manda', title: 'Sí, mándalo' }, + { id: 'seq_sponsor_caso_ndasafe:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_sponsor_caso_ndasafe:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja ayudando a equipos comerciales y casi siempre el problema aparece en uno de tres lugares. En tu equipo, ¿cuál duele más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_pipeline:leads', title: 'Leads que no llegan' }, + { id: 'seq_comercial_pipeline:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_comercial_pipeline:contexto', title: 'WhatsApp sin contexto' }, + { id: 'seq_comercial_pipeline:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_wa_crm:historial', title: 'Historial de clientes' }, + { id: 'seq_comercial_wa_crm:seguimiento', title: 'Seguimiento' }, + { id: 'seq_comercial_wa_crm:prioridad', title: 'Prioridad de leads' }, + { id: 'seq_comercial_wa_crm:datos', title: 'Datos para decidir' }, + { id: 'seq_comercial_wa_crm:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_motor_prospeccion:segmento', title: 'Segmento claro' }, + { id: 'seq_comercial_motor_prospeccion:mensaje', title: 'Mensaje repetible' }, + { id: 'seq_comercial_motor_prospeccion:seguimiento', title: 'Seguimiento medible' }, + { id: 'seq_comercial_motor_prospeccion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja con equipos de finanzas que terminan operando a mano porque no pueden confiar rápido en sus datos. En tu caso, ¿dónde está el mayor dolor hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cfo_control:cartera', title: 'Cartera' }, + { id: 'seq_cfo_control:comisiones', title: 'Comisiones' }, + { id: 'seq_cfo_control:reportes', title: 'Reportes' }, + { id: 'seq_cfo_control:conciliacion', title: 'Conciliación' }, + { id: 'seq_cfo_control:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + step2: { + respondo: 'Te leo. Cuéntame qué información te cuesta más tener confiable y a tiempo (cartera, cobranza, reportes), y se la paso a Alek aterrizada.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cfo_cartera_datos:respondo', title: 'Te respondo aquí' }, + { id: 'seq_cfo_cartera_datos:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_cfo_cartera_datos:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_cfo_comisiones:reglas', title: 'Reglas manuales' }, + { id: 'seq_cfo_comisiones:excepciones', title: 'Excepciones' }, + { id: 'seq_cfo_comisiones:datos', title: 'Datos que no cuadran' }, + { id: 'seq_cfo_comisiones:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + step2: { + mapa: 'Va el mapa corto: WhatsApp Cloud API → ForgeChat (bandeja y reglas) → n8n (orquestación) → CRM y memoria por contacto. Todo con permisos mínimos, trazabilidad de cada mensaje y aprobación humana antes de cualquier envío sensible. Si quieres el detalle técnico completo, Alek te lo manda directo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_arquitectura:mapa', title: 'Mándame el mapa' }, + { id: 'seq_tecnico_arquitectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_arquitectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada?`, + openerOptions: { + kind: 'list', + button: 'Elegir riesgo', + options: [ + { id: 'seq_tecnico_rollback:permisos', title: 'Permisos' }, + { id: 'seq_tecnico_rollback:datos', title: 'Datos' }, + { id: 'seq_tecnico_rollback:trazabilidad', title: 'Trazabilidad' }, + { id: 'seq_tecnico_rollback:reversibilidad', title: 'Reversibilidad' }, + { id: 'seq_tecnico_rollback:dependencia', title: 'Dependencia operativa' }, + { id: 'seq_tecnico_rollback:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + step2: { + respondo: 'Te leo. Dime qué condición tendría que cumplirse para que la prueba te parezca segura (permisos, alcance, datos, reversibilidad) y la registro tal cual para Alek.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_integracion:respondo', title: 'Te respondo aquí' }, + { id: 'seq_tecnico_integracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_integracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +// Openers v2: saludo con primer nombre (D9). Limpia el sufijo de QA y toma el +// primer token; si no hay nada usable devuelve el valor original. +function ia360FirstNameFrom(name) { + const raw = String(name || '').trim().replace(/\s+WhatsApp IA360$/i, '').trim(); + return raw.split(/\s+/).filter(Boolean)[0] || raw; +} + +// Openers v2: arma el objeto `interactive` de un opener desde sequence.openerOptions +// (kind 'buttons' ≤3 opciones, kind 'list' 4+). Sin header ni footer: el copy +// aprobado por Alek va fiel en body.text. Devuelve null si la secuencia no tiene +// openerOptions (esas siguen saliendo como texto plano). +function buildIa360OpenerInteractive({ sequence, bodyText }) { + const opts = sequence && sequence.openerOptions; + if (!opts || !Array.isArray(opts.options) || !opts.options.length) return null; + if (opts.kind === 'list') { + return { + type: 'list', + body: { text: bodyText }, + action: { + button: opts.button || 'Elegir', + sections: [{ + title: 'Opciones', + rows: opts.options.map(o => ({ id: o.id, title: o.title, ...(o.description ? { description: o.description } : {}) })), + }], + }, + }; + } + return { + type: 'button', + body: { text: bodyText }, + action: { + buttons: opts.options.slice(0, 3).map(o => ({ type: 'reply', reply: { id: o.id, title: o.title } })), + }, + }; +} + +// ── G-C: ruteo real de respuestas seq_* (openers v2) ───────────────────────── +// Un botón/fila `seq_:` del catálogo persona-first SIEMPRE +// recibe un siguiente paso real: paso 2 definido en el catálogo (`step2`), +// manejo semántico compartido (alek_directo / ahora_no / horarios) o acuse +// específico con eco de la elección + aviso al owner con la nextAction de la +// secuencia. Devuelve true si lo manejó; false SOLO para ids seq_* que no están +// en el catálogo (esos sí caen al fallback global, porque son inválidos). +async function handleIa360SequenceReply({ record, replyId, contact = null }) { + const m = /^seq_([a-z0-9_]+):([a-z0-9_]+)$/.exec(String(replyId || '').trim().toLowerCase()); + if (!m) return false; + const sequenceId = m[1]; + const optionKey = m[2]; + const found = findIa360SequenceFlow(sequenceId); + if (!found) return false; + const { sequence } = found; + const option = (sequence.openerOptions?.options || []) + .find(o => String(o.id).toLowerCase() === `seq_${sequenceId}:${optionKey}`); + if (!option) return false; + try { + const ctx = contact || await loadIa360ContactContext(record).catch(() => null); + const cf = ctx?.custom_fields || {}; + const contactName = ctx?.name || record.contact_name || record.contact_number; + const safeName = sanitizeIa360IntroName(contactName) || record.contact_number; + const nowIso = new Date().toISOString(); + + // Guard de estado (paridad con el router 100M): si la conversación ya avanzó + // a agenda/reunión/handoff humano, un botón seq_* de un opener viejo NO mueve + // el deal hacia atrás; responde continuidad y el owner se entera del tap. + const guard = await ia360HundredMAdvancedGuard(record); + if (guard.advanced) { + await enqueueIa360Text({ record, label: `ia360_seq_continuity_${sequenceId}`, body: guard.body }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_stale_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) tocó "${option.title}" de un opener viejo ("${sequence.label}"), pero su proceso ya va más adelante. No moví nada; le respondí con continuidad.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] stale notify:', e.message)); + return true; + } + + // Dedupe de doble tap del contacto: misma secuencia+opción ya registrada → + // continuidad corta, sin re-registro ni avisos duplicados al owner. + const prev = cf.ia360_seq_last_response || null; + if (prev && prev.sequence === sequenceId && prev.option === optionKey) { + await enqueueIa360Text({ + record, + label: `ia360_seq_dup_${sequenceId}`, + body: `Ya tengo registrada tu respuesta "${option.title}" y Alek ya tiene el contexto. Quedo al pendiente; cualquier cosa me escribes por aquí.`, + }); + return true; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-seq-respuesta', `seq-${sequenceId}`], + customFields: { + ia360_seq_last_response: { sequence: sequenceId, option: optionKey, title: option.title, at: nowIso }, + ia360_ultima_respuesta: option.title, + ultimo_cta_enviado: `ia360_seq_reply_${sequenceId}_${optionKey}`, + }, + }).catch(e => console.error('[ia360-seq] merge state:', e.message)); + + const notifyOwner = (detalle) => sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_reply_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) respondió "${option.title}" al opener "${sequence.label}". ${detalle}`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] notify owner:', e.message)); + + // 1) Salida directa con Alek. + if (optionKey === 'alek_directo') { + await enqueueIa360Text({ + record, + label: `ia360_seq_alek_directo_${sequenceId}`, + body: 'Perfecto, le aviso a Alek ahora mismo para que te escriba directo. Gracias por responder.', + }); + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió hablar directo con Alek.`, + }).catch(e => console.error('[ia360-seq] deal alek_directo:', e.message)); + await notifyOwner('Pidió que le escribas TÚ directo. Deal en "Requiere Alek".'); + return true; + } + + // 2) Cierre suave → nutrición. + if (optionKey === 'ahora_no') { + await enqueueIa360Text({ + record, + label: `ia360_seq_ahora_no_${sequenceId}`, + body: 'De acuerdo, no te insisto. Si más adelante quieres retomarlo, me escribes por aquí y seguimos donde lo dejamos.', + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: {}, + }).catch(e => console.error('[ia360-seq] tag nutricion:', e.message)); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: ahora no. Pasa a nutrición suave.`, + }).catch(e => console.error('[ia360-seq] deal ahora_no:', e.message)); + await notifyOwner('Respondió que ahora no; queda en nutrición suave.'); + return true; + } + + // 3) Agenda con permiso (referido_permiso_agenda:horarios). + if (optionKey === 'horarios') { + await enqueueIa360Interactive({ + record, + label: `ia360_seq_horarios_${sequenceId}`, + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + body: { text: 'Perfecto. ¿Qué ventana te acomoda mejor para la llamada con Alek?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió horarios. Deal a "Agenda en proceso".`, + }).catch(e => console.error('[ia360-seq] deal horarios:', e.message)); + await notifyOwner('Pidió horarios para una llamada contigo. Deal en "Agenda en proceso".'); + return true; + } + + // 4) Paso 2 definido en el catálogo. + const step2 = sequence.step2 && sequence.step2[optionKey]; + if (step2) { + await enqueueIa360Text({ + record, + label: `ia360_seq_step2_${sequenceId}_${optionKey}`, + body: step2, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Paso 2 de la secuencia enviado.`, + }).catch(e => console.error('[ia360-seq] deal step2:', e.message)); + await notifyOwner(`Le envié el paso 2 de la secuencia. Next action sugerida: ${sequence.nextAction}`); + return true; + } + + // 5) Sin paso 2 en el catálogo (temas de lista): acuse específico con eco de + // la elección + aviso al owner con la respuesta y la next action sugerida. + await enqueueIa360Text({ + record, + label: `ia360_seq_ack_${sequenceId}_${optionKey}`, + body: `Gracias, registré tu respuesta: "${option.title}". Le paso este contexto a Alek para que el siguiente paso vaya directo a eso, sin rodeos. Te escribe él con una propuesta concreta.`, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Acuse enviado; siguiente paso con Alek.`, + }).catch(e => console.error('[ia360-seq] deal ack:', e.message)); + await notifyOwner(`Next action sugerida: ${sequence.nextAction}`); + return true; + } catch (err) { + console.error('[ia360-seq] reply error:', err.message); + // Nunca mudo: acuse mínimo aunque el registro haya fallado. + await enqueueIa360Text({ + record, + label: 'ia360_seq_ack_error', + body: 'Recibí tu respuesta y ya se la pasé a Alek. Te escribe él en corto.', + }).catch(() => {}); + return true; + } +} + +// ── G-C: CTAs únicos — alias de botones de template (quick replies de texto) ── +// Los templates fríos (p. ej. ia360_referido_apertura = template 41, +// ia360_aliado_mapa_colaboracion = template 43) llegan con button.payload = +// TEXTO del botón, no un id estructurado, por lo que "Sí, cuéntame" era ambiguo +// entre Revenue OS y Referidos. Revenue OS se resuelve ANTES en el dispatch +// (handleRevenueOsButton, gateado por ia360_revenue_state); si no era suyo, este +// alias traduce el texto al id seq_* ÚNICO de la secuencia persona-first cuyo +// opener realmente se le envió al contacto (pf.sequence_candidate.id + pf.send). +const IA360_SEQ_ALIAS_NEGATIVE = new Set(['ahora no', 'por ahora no', 'no por ahora']); +const IA360_SEQ_ALIAS_HANDOFF = new Set(['que me escriba alek', 'hablar con alek']); +// Solo frases genuinamente afirmativas. Los títulos exactos del catálogo +// ("Proponme horarios", "Te respondo aquí", etc.) se resuelven por match de +// título, no por semántica: ponerlos aquí fabricaría elecciones equivocadas. +const IA360_SEQ_ALIAS_AFFIRMATIVE = new Set([ + 'sí, cuéntame', 'si, cuéntame', 'sí, cuentame', 'si, cuentame', + 'sí, cuéntame más', 'si, cuentame mas', + 'sí, pregúntame', 'si, preguntame', + 'sí, mándalo', 'si, mandalo', + 'sí, compártelo', 'si, compartelo', + 'sí, a ver', 'si, a ver', + 'sí, te cuento', 'si, te cuento', + 'sí, hay un tema', 'si, hay un tema', + 'me interesa', 'sí, me interesa', 'si, me interesa', +]); + +function resolveIa360TemplateButtonAlias({ replyId, contact }) { + const key = String(replyId || '').trim().toLowerCase(); + if (!key || key.startsWith('seq_')) return null; + const isNeg = IA360_SEQ_ALIAS_NEGATIVE.has(key); + const isHand = IA360_SEQ_ALIAS_HANDOFF.has(key); + const isAff = IA360_SEQ_ALIAS_AFFIRMATIVE.has(key); + if (!isNeg && !isHand && !isAff) return null; + const pf = contact?.custom_fields?.ia360_persona_first; + const seqId = pf?.sequence_candidate?.id; + if (!seqId || !pf?.send?.sent_at) return null; // solo si su opener realmente salió + const found = findIa360SequenceFlow(seqId); + if (!found) return null; + const opts = found.sequence.openerOptions?.options || []; + // 1) Match exacto por título visible del botón. + const byTitle = opts.find(o => String(o.title).trim().toLowerCase() === key); + if (byTitle) return String(byTitle.id).toLowerCase(); + // 2) Por semántica: negativo → ahora_no; handoff → alek_directo; afirmativo → + // la primera opción que no sea ninguna de las dos (el camino afirmativo). + const bySuffix = (suffix) => opts.find(o => String(o.id).toLowerCase().endsWith(`:${suffix}`)); + if (isNeg) { const o = bySuffix('ahora_no'); return o ? String(o.id).toLowerCase() : null; } + if (isHand) { const o = bySuffix('alek_directo'); return o ? String(o.id).toLowerCase() : null; } + // Afirmativo SOLO cuando es inequívoco: la secuencia declara su opción de + // template (templateAliasOption) o existe exactamente UNA opción no terminal. + // Con varias opciones posibles (listas de temas) NO se fabrica una elección: + // se devuelve null y el fallback global acusa recibo y avisa al owner. + if (found.sequence.templateAliasOption) { + const o = bySuffix(found.sequence.templateAliasOption); + if (o) return String(o.id).toLowerCase(); + } + const nonTerminal = opts.filter(o => { + const id = String(o.id).toLowerCase(); + return !id.endsWith(':ahora_no') && !id.endsWith(':alek_directo'); + }); + return nonTerminal.length === 1 ? String(nonTerminal[0].id).toLowerCase() : null; +} + +// ── G-C: anti-loop del router 100M ─────────────────────────────────────────── +// Nodos que en las pruebas reales generaron ciclos (doc 2026-06-10, chat_history +// 1068-1079 y 1135-1142): exploración, mecanismos, mapa y ejemplo. Una visita +// repetida ya no reenvía el bloque completo: responde una versión condensada con +// salidas terminales (agendar / llamada / más adelante). +const IA360_100M_LOOP_PRONE = new Set([ + 'explorando', + 'mecanismo-whatsapp-crm', + 'mecanismo-erp-bi', + 'mecanismo-agentic-followup', + 'mapa-30-60-90-solicitado', + 'ejemplo-solicitado', +]); +// Etapas donde la conversación ya avanzó a agenda/handoff humano: un botón 100M +// de un mensaje viejo NO debe reabrir la rama (guard de estado/versión). +const IA360_100M_ADVANCED_STAGES = new Set(['Agenda en proceso', 'Reunión agendada', 'Requiere Alek']); + +async function ia360HundredMAdvancedGuard(record) { + const out = { advanced: false, body: '', visited: {}, visitedOk: false }; + try { + const contact = await loadIa360ContactContext(record).catch(() => null); + const cf = contact?.custom_fields || {}; + if (contact) { + out.visited = (cf.ia360_100m_visited && typeof cf.ia360_100m_visited === 'object') ? cf.ia360_100m_visited : {}; + out.visitedOk = true; // lectura confiable: se puede escribir sin pisar el mapa + } + // Solo reuniones FUTURAS cuentan como "en curso": el cache crudo ia360_bookings + // conserva citas pasadas y atraparía al contacto para siempre. + const bookings = await loadIa360BookingsForList(record.contact_number).catch(() => []); + const hasBooking = Array.isArray(bookings) && bookings.length > 0; + let stageName = ''; + const deal = await getActiveNonTerminalIa360Deal(record).catch(() => null); + if (deal) stageName = deal.stage_name || ''; + if (hasBooking || IA360_100M_ADVANCED_STAGES.has(stageName)) { + out.advanced = true; + out.body = (hasBooking || stageName === 'Reunión agendada') + ? 'Vi tu respuesta, pero tu proceso ya va más adelante: tienes una reunión en curso con Alek. Sigo con eso para no regresarte al inicio. Si quieres mover la reunión o retomar otro tema, dímelo por aquí.' + : 'Vi tu respuesta a un mensaje anterior, pero tu proceso ya va más adelante: estamos en la parte de agenda con Alek. Sigo con eso para no darte vueltas; si quieres retomar otro tema, dímelo por aquí y lo vemos.'; + } + } catch (err) { + console.error('[ia360-100m] advanced guard:', err.message); + } + return out; +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + // Openers v2: saludo con primer nombre (D9) + quién hizo la introducción (D6). + const draftName = ia360FirstNameFrom(name); + const quienIntro = String(customFields.quien_intro || '').trim() || null; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name: draftName, quienIntro }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + ...(sequence.openerOptions && Array.isArray(sequence.openerOptions.options) + ? ['', `Opciones del mensaje (${sequence.openerOptions.kind === 'list' ? 'lista' : 'botones'}): ${sequence.openerOptions.options.map(o => o.title).join(' | ')}`] + : []), + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { + text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, + }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows: [ + { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ], + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // G-C: dedupe de doble tap. Si esta misma secuencia ya fue aprobada y su envío + // ya salió SIN fallar, un segundo tap de la tarjeta NO debe generar otro egress. + // Un envío fallido NO bloquea: el owner puede reintentar con la misma tarjeta. + if (pf.approval?.status === 'approved' && pf.send?.sent_at && String(pf.send.send_status || '').toLowerCase() !== 'failed' && !pf.send.error) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_dup', + body: `Ese opener ("${sequence.label}") ya se había enviado a ${name} (${pf.send.sent_at}). Detecté un doble tap y no envié nada nuevo.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // GUARDIA cliente_expansion (D7): la secuencia presupone un proyecto andando. + // Solo dispara si el contacto tiene un deal vivo (status='open') en P2 (IA360 + // WhatsApp Revenue Pipeline) o P7 (Champions). Sin deal vivo → bloquear con aviso. + // G-C: con try/catch — si la consulta falla, el owner se entera (nunca mudo) y + // NO se envía nada (fail-closed). + if (sequence.requiresLiveDeal) { + let liveRows; + try { + ({ rows: liveRows } = await pool.query( + `SELECT 1 + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') + AND d.contact_wa_number = $1 + AND d.contact_number = $2 + AND d.status = 'open' + LIMIT 1`, + [record.wa_number, targetContact] + )); + } catch (liveErr) { + console.error('[ia360-approve] live deal check failed:', liveErr.message); + return deny('live_deal_check_failed', `No pude verificar si ${name} tiene un proyecto activo (error de base de datos). Por seguridad no envié nada; reintenta en un momento.`); + } + if (!liveRows.length) { + return deny('no_live_deal', `${name} no tiene un proyecto activo (deal vivo en P2/P7). La secuencia ${sequence.id} solo aplica a clientes con proyecto en curso; elige otra secuencia. No envié nada.`); + } + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); + // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + // Openers v2: dentro de ventana el opener sale como interactive (botones/lista) + // con el copy aprobado en el readout; secuencias sin openerOptions siguen en texto. + const openerInteractive = buildIa360OpenerInteractive({ sequence, bodyText: pf.sequence_candidate.draft }); + let sent; + let handlerFor; + if (openerInteractive) { + sent = await enqueueIa360Interactive({ + record: targetRecord, + label: openerLabel, + messageBody: `IA360 opener: ${sequence.label}`, + interactive: openerInteractive, + dedupSuffix: `:opener:${targetContact}`, + }); + handlerFor = `${record.message_id}:opener:${targetContact}`; + } else { + sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + handlerFor = `${record.message_id}:direct:${targetContact}`; + } + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(handlerFor); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + // G-C: un opener nuevo abre un ciclo nuevo — la respuesta del ciclo anterior + // no debe activar el dedupe del router seq_* (el contacto debe poder volver + // a elegir la misma opción y recibir su paso 2). + ia360_seq_last_response: null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── Expediente del owner: "qué sabes de " ────────────────── +// Comando read-only del owner: arma un expediente con los facts y eventos de +// coexistence.ia360_memory_* para un contacto, resuelto por número o por +// nombre (tolerante a acentos y a typos simples tipo Emmanuel/Emanuel). +// Egress SOLO vía sendIa360DirectText; nunca escribe memoria y SIEMPRE +// responde algo (sin expediente / candidatos / error), nunca queda mudo. +const IA360_BOT_WA_NUMBER = '5213321594582'; // número del bot: jamás es contacto + +function parseIa360OwnerMemoryQuery(body) { + const text = String(body || '').trim(); + const m = text.match(/^¿?\s*(?:qu[eé]|qui[eé]n)\s+sabes\s+(?:de\s+la|de\s+el|del|de|sobre)\s+(.+?)\s*\?*$/i); + if (!m) return null; + const q = m[1].trim(); + return q || null; +} + +// Normaliza para comparar nombres: minúsculas, sin acentos y con letras +// repetidas colapsadas ("Emmanuel" y "Emanuel" → "emanuel"). +function ia360NormalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +async function resolveIa360MemoryTarget(query) { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + // Número directo: 10 dígitos MX → prefijo 521 (formato ForgeChat). + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const { rows } = await pool.query( + `SELECT DISTINCT contact_number, contact_name FROM ( + SELECT contact_number, contact_name + FROM coexistence.ia360_memory_events + WHERE contact_name IS NOT NULL AND contact_number IS NOT NULL + UNION ALL + SELECT contact_number, COALESCE(name, profile_name) AS contact_name + FROM coexistence.contacts + WHERE COALESCE(name, profile_name) IS NOT NULL AND contact_number IS NOT NULL + ) t + WHERE contact_number <> $1`, + [IA360_BOT_WA_NUMBER] + ); + const needle = ia360NormalizeNameForMatch(query); + if (!needle) return { kind: 'none', candidates: [] }; + const byNumber = new Map(); + for (const r of rows) { + if (!ia360NormalizeNameForMatch(r.contact_name).includes(needle)) continue; + if (!byNumber.has(r.contact_number)) byNumber.set(r.contact_number, r); + } + const candidates = [...byNumber.values()]; + if (!candidates.length) return { kind: 'none', candidates: [] }; + if (candidates.length > 1) return { kind: 'ambiguous', candidates }; + return { kind: 'name', candidates }; +} + +async function buildIa360ContactDossier(contactNumber) { + const num = normalizePhone(contactNumber); + const { rows: factRows } = await pool.query( + `SELECT project_name, persona, role, account_name, preference, objection, + recurring_pain, affected_process, missing_metric + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + ORDER BY last_seen_at DESC, id DESC`, + [num] + ); + const { rows: eventRows } = await pool.query( + `SELECT contact_name, area, signal_type, summary + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC, id DESC + LIMIT 12`, + [num] + ); + if (!factRows.length && !eventRows.length) return null; + + const name = eventRows.find(e => e.contact_name)?.contact_name || null; + const header = [`Expediente IA360: ${name || 'contacto'} (${num})`]; + const meta = []; + const accountRow = factRows.find(f => f.account_name); + const projectRow = factRows.find(f => f.project_name); + const personaRow = factRows.find(f => f.persona); + if (accountRow) meta.push(`Cuenta: ${accountRow.account_name}`); + if (projectRow) meta.push(`Proyecto: ${projectRow.project_name}`); + if (personaRow) meta.push(`Persona: ${personaRow.persona}`); + if (meta.length) header.push(meta.join(' · ')); + + // Los facts viven duplicados por el doble keying de contact_wa_number + // (monolito vs lookup v2): dedupe por contenido, no por fila. + const factLines = []; + const seenFacts = new Set(); + for (const f of factRows) { + for (const field of ['preference', 'objection', 'recurring_pain', 'affected_process', 'missing_metric']) { + const val = String(f[field] || '').trim(); + if (!val) continue; + const key = `${field}:${val}`; + if (seenFacts.has(key)) continue; + seenFacts.add(key); + factLines.push(`- ${val.length > 300 ? `${val.slice(0, 297)}...` : val}`); + } + } + const eventLines = []; + const seenEvents = new Set(); + for (const e of eventRows) { + const val = String(e.summary || '').trim(); + if (!val) continue; + const key = `${e.area}|${e.signal_type}|${val}`; + if (seenEvents.has(key)) continue; + seenEvents.add(key); + eventLines.push(`- [${e.area}/${e.signal_type}] ${val.length > 220 ? `${val.slice(0, 217)}...` : val}`); + } + + const lines = [...header, '']; + if (factLines.length) lines.push(`Facts (${factLines.length}):`, ...factLines, ''); + if (eventLines.length) lines.push(`Eventos recientes (${eventLines.length}):`, ...eventLines); + let body = lines.join('\n').trim(); + // Límite duro de WhatsApp: 4096 chars por texto. + if (body.length > 3900) body = `${body.slice(0, 3880)}\n... (recortado)`; + return body; +} + +async function handleIa360OwnerMemoryQuery({ record, query }) { + let body; + try { + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + body = `Sin expediente: no encontré facts ni eventos para "${query}". Revisa el nombre o mándame el número completo.`; + } else if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + body = `Encontré varios contactos que coinciden con "${query}". ¿De cuál quieres el expediente?\n${list}\n\nMándame "qué sabes de " para verlo.`; + } else { + const dossier = await buildIa360ContactDossier(target.candidates[0].contact_number); + body = dossier + || `Sin expediente: el contacto ${target.candidates[0].contact_number} no tiene facts ni eventos guardados todavía.`; + } + } catch (err) { + console.error('[ia360-expediente] dossier error:', err.message); + body = `No pude leer el expediente de "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); +} + +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // ── G-C: ruteo de respuestas a openers v2 (ids seq_* y alias de template) ── + // Va DESPUÉS de Revenue OS (que resuelve su propio "Sí, cuéntame" gateado por + // estado) y ANTES del embudo 100M. Un id seq_* del catálogo NUNCA cae al + // fallback global. + if (replyId && replyId.startsWith('seq_')) { + if (await handleIa360SequenceReply({ record, replyId })) return; + } else if (replyId || answer) { + const aliasKey = String(replyId || answer || '').trim().toLowerCase(); + if (IA360_SEQ_ALIAS_NEGATIVE.has(aliasKey) || IA360_SEQ_ALIAS_HANDOFF.has(aliasKey) || IA360_SEQ_ALIAS_AFFIRMATIVE.has(aliasKey)) { + try { + const aliasContact = await loadIa360ContactContext(record).catch(() => null); + const aliased = resolveIa360TemplateButtonAlias({ replyId: aliasKey, contact: aliasContact }); + if (aliased && await handleIa360SequenceReply({ record, replyId: aliased, contact: aliasContact })) return; + } catch (aliasErr) { + console.error('[ia360-seq] alias error:', aliasErr.message); + } + } + } + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + // G-C anti-loop: "No prioritario" ya NO ofrece "Aplicarlo" (reabría la rama + // comercial); las salidas son nutrición ("Más adelante") o baja. + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + // ── G-C: anti-loop del router 100M ────────────────────────────────────── + // 'baja' (optout) SIEMPRE pasa: la salida del contacto no se bloquea nunca. + if (flow100m.tag !== 'no-contactar') { + try { + const guard = await ia360HundredMAdvancedGuard(record); + // Guard de estado/versión: la conversación ya avanzó a agenda/reunión/ + // handoff humano → un botón de un mensaje viejo NO reabre la rama. + if (guard.advanced) { + await enqueueIa360Text({ record, label: 'ia360_100m_continuity', body: guard.body }); + return; + } + // Nodo loop-prone repetido → versión condensada con salidas terminales, + // no el bloque completo otra vez. Si la lectura del contacto falló, NO se + // escribe el mapa de visitas (se pisaría con un objeto vacío). + const visited = guard.visited || {}; + if (guard.visitedOk && IA360_100M_LOOP_PRONE.has(flow100m.tag)) { + if (visited[flow100m.tag]) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: (Number(visited[flow100m.tag]) || 0) + 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + await enqueueIa360Interactive({ + record, + label: 'ia360_100m_condensed', + messageBody: `IA360 100M: ${flow100m.title} (resumen)`, + interactive: { + type: 'button', + body: { text: `Eso ya lo vimos: ${flow100m.title}. Para no darte vueltas con lo mismo, mejor dime cómo cerramos: ¿agendamos una llamada corta con Alek o lo dejamos para más adelante?` }, + footer: { text: 'IA360 · sin vueltas' }, + action: { + buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada con Alek' } }, + { type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }, + ], + }, + }, + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + } + } catch (guardErr) { + console.error('[ia360-100m] guard error:', guardErr.message); + } + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + }, + }); + return; + } + + // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── + // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo o + // malformado). Los ids seq_* del catálogo y los quick replies de template con + // estado persona-first ya se rutean arriba (handleIa360SequenceReply + alias); + // aquí solo cae lo verdaderamente desconocido. El contacto siempre recibe + // acuse y el owner se entera. try/catch terminal: nunca tumba el webhook. + try { + const fallbackId = replyId || answer || '(sin id)'; + console.warn('[ia360-fallback] unhandled interactive reply contact=%s id=%s body=%s', record.contact_number || '-', fallbackId, String(record.message_body || '').slice(0, 80)); + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_ack', + body: `Recibí tu respuesta "${String(record.message_body || fallbackId).slice(0, 60)}", pero aún no tengo una acción conectada para ese botón (${fallbackId}). No hice ningún cambio.`, + }); + return; + } + await enqueueIa360Text({ + record, + label: 'ia360_interactive_fallback', + body: 'Recibí tu respuesta y la estoy ubicando para darte una respuesta útil. Si es urgente, Alek también puede escribirte directo.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_notice', + body: `Alek, ${record.contact_name || record.contact_number} (${record.contact_number}) respondió "${String(record.message_body || fallbackId).slice(0, 60)}" (id: ${fallbackId}) y no tengo un manejador para esa opción. Le acusé recibo; revisa si quieres tomarlo tú.`, + targetContact: record.contact_number, + ownerBudget: true, + }); + } catch (fbErr) { + console.error('[ia360-fallback] interactive fallback error:', fbErr.message); + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── EXPEDIENTE: comando del owner "qué sabes de " ── + // Mismo patrón que "idea:": va ANTES del canary Brain v2 (el owner + // está en la allowlist y el canary haría continue). Read-only sobre + // ia360_memory_facts/events; responde SIEMPRE (nunca queda mudo). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const memQuery = parseIa360OwnerMemoryQuery(record.message_body); + if (memQuery) { + await handleIa360OwnerMemoryQuery({ record, query: memQuery }) + .catch(e => console.error('[ia360-expediente] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +// OPENERS V2 — vista previa de un opener al WhatsApp del OWNER (nunca a un +// contacto). Renderiza el draft v2 (primer nombre + quien_intro opcional) y el +// interactive (botones/lista) tal como lo vería el contacto. Único egress: +// sendOwnerInteractive / sendIa360DirectText -> messageSender. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). +router.post('/internal/ia360-openers/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const sequenceId = String(b.sequence_id || '').trim().toLowerCase(); + const found = findIa360SequenceFlow(sequenceId); + if (!found) return res.status(422).json({ ok: false, error: 'unknown_sequence', sequence_id: sequenceId }); + const { sequence } = found; + const sampleName = ia360FirstNameFrom(String(b.name || 'Alek').trim() || 'Alek'); + const quienIntro = String(b.quien_intro || '').trim() || null; + const bodyText = typeof sequence.draft === 'function' ? sequence.draft({ name: sampleName, quienIntro }) : String(sequence.draft || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const synthetic = { + wa_number: waNumber, + contact_number: IA360_OWNER_NUMBER, + message_id: `opener-preview-${sequenceId}-${Date.now()}`, + message_type: 'text', + direction: 'incoming', + }; + const interactive = buildIa360OpenerInteractive({ sequence, bodyText }); + let sent; + if (interactive) { + // ownerBudget=false: la preview es una petición explícita del owner; no debe + // caer en el presupuesto anti-spam de notificaciones. + sent = await sendOwnerInteractive({ + record: synthetic, + label: `ia360_opener_preview_${sequenceId}`, + messageBody: `IA360 preview opener ${sequenceId}`, + interactive, + }); + } else { + sent = await sendIa360DirectText({ + record: synthetic, + toNumber: IA360_OWNER_NUMBER, + label: `ia360_opener_preview_${sequenceId}`, + body: bodyText, + }); + } + return res.status(sent ? 200 : 502).json({ + ok: Boolean(sent), + schema: 'ia360_opener_preview.v1', + sequence_id: sequenceId, + kind: interactive ? (interactive.type === 'list' ? 'list' : 'buttons') : 'text', + body_preview: bodyText, + }); + } catch (err) { + console.error('[ia360-openers] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-gg-20260602T232921Z b/backend/src/routes/webhook.js.bak-pre-gg-20260602T232921Z new file mode 100644 index 0000000..199d914 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-gg-20260602T232921Z @@ -0,0 +1,1707 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|cambiar|otro d[ií]a|otra hora|otro horario|posponer|recorrer|adelantar|cancel/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if (agent.action === 'offer_slots' && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId] || answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-glive-20260611T144600Z b/backend/src/routes/webhook.js.bak-pre-glive-20260611T144600Z new file mode 100644 index 0000000..6a8dbbd --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-glive-20260611T144600Z @@ -0,0 +1,8293 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run -> fallback_required', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + // Important: dry-run memory learning is NOT a customer response. Returning true + // made the parent handler believe the message was handled, which produced the + // active-client silence bug. Return false so handleIa360FreeText sends the + // universal holding fallback + owner alert instead of leaving WhatsApp quiet. + return false; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +// G-C: el nombre del introductor viene de push name / vCard (texto controlado por +// el remitente). Se sanitiza antes de persistir: sin caracteres de control ni +// saltos de línea, sin llaves de placeholder, espacios colapsados y tope de 60 +// caracteres. Devuelve null si no queda nada usable. +function sanitizeIa360IntroName(raw) { + const clean = String(raw || '') + .replace(/[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ' ') + .replace(/[{}]/g, '') + .replace(/\s+/g, ' ') + .trim(); + // Corte por code points (no por unidades UTF-16): un emoji en la frontera de + // los 60 caracteres no deja un surrogate suelto que rompa el jsonb al persistir. + const capped = Array.from(clean).slice(0, 60).join('').trim(); + if (!capped) return null; + if (!/[\p{L}]/u.test(capped)) return null; // sin letras (solo dígitos/símbolos) no sirve como nombre + return capped; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + // quien_intro (D6): si el vCard lo comparte un CONTACTO (no el owner), esa + // persona es quien hizo la introducción. Se guarda el NOMBRE para que el + // opener referido_contexto pueda decir "nos presentó X". Si lo manda el owner, + // el dato queda pendiente (el placeholder {{quien_intro}} bloquea el copy). + // G-C: sanitizado (push name inyectable), sin auto-introducción (vCard propio) + // y sin pisar un quien_intro ya capturado. + let quienIntro = null; + const sharerIsSelf = normalizePhone(record.contact_number || '') === normalizePhone(shared.contactNumber || ''); + if (record.contact_number && !sharerIsSelf && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + try { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(record.contact_number)] + ); + quienIntro = sanitizeIa360IntroName(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || ''); + if (quienIntro) { + const { rows: existingRows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(shared.contactNumber)] + ); + if (String(existingRows[0]?.quien_intro || '').trim()) quienIntro = null; // ya hay introductor registrado: no pisar + } + } catch (e) { + console.error('[ia360-vcard] quien_intro lookup:', e.message); + quienIntro = null; + } + } + const customFields = { + ...(quienIntro ? { quien_intro: quienIntro } : {}), + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +// ============================================================================ +// G-WIN — Quick-win "Mapa de cartera" (Pipeline 7 "Champions — Adopción y +// expansión", persona cliente activo / CFO). Patrón Revenue OS P5: máquina de +// estados en contacts.custom_fields.ia360_cartera_state ('' → esperando_tabla +// → mapa_entregado), handlers gateados que CORTAN el embudo, egress único vía +// enqueueIa360Text / sendIa360DirectText → sendQueue. +// PASO 1 (texto cartera/saldos que no cuadran) → Hallazgo / Impacto / Dato +// faltante (pide la tabla EN TEXTO; el bot no lee imágenes) / +// Siguiente acción. SIN pitch y SIN agenda. +// PASO 2 (tabla pegada en texto) → mapa estructurado al contacto + nota +// completa en su deal P7 + cola ia360_docs_sync + readout al owner + +// deal a "Quick win entregado" (solo hacia adelante). +// GUARDRAIL: nunca agenda automática, no nutrición, no insistencia; si el +// mensaje no es de cartera, el flujo NO se activa y el agente genérico sigue. +// ============================================================================ +const CHAMPIONS_PIPELINE_NAME = 'Champions — Adopción y expansión'; +const CARTERA_STAGE_VALIDACION = 'Validación en curso'; +const CARTERA_STAGE_QUICKWIN = 'Quick win entregado'; +const CARTERA_FORMATO_TABLA = 'Cliente | Saldo en portal | Saldo correcto | Fecha de corte | Responsable'; + +const IA360_CARTERA_COPY = { + paso1: [ + 'Gracias por el aviso. Lo dejo ordenado:', + '', + '*Hallazgo:* los saldos que muestra el portal no cuadran con los saldos reales de cartera; hoy la corrección depende de revisiones manuales y la diferencia no se ve en un solo lugar.', + '', + '*Impacto:* mientras el portal muestre saldos incorrectos, cobranza trabaja con cifras que el cliente puede rebatir y el seguimiento pierde confiabilidad.', + '', + '*Dato faltante:* mándame la tabla aquí mismo, en texto, una línea por cuenta con este formato:', + CARTERA_FORMATO_TABLA, + 'Importante: no puedo leer imágenes ni archivos adjuntos; si la tienes en foto o en Excel, pégamela como texto.', + '', + '*Siguiente acción:* en cuanto la reciba, la convierto en tu mapa de cartera (cuenta → saldo portal → saldo correcto → fecha de corte → responsable → siguiente acción) y lo dejo registrado para Alek.', + ].join('\n'), + pideTexto: [ + 'Recibí tu archivo, pero no puedo leer imágenes ni documentos adjuntos.', + '¿Me pegas la tabla aquí mismo en texto? Una línea por cuenta:', + CARTERA_FORMATO_TABLA, + ].join('\n'), + recordatorioFormato: [ + 'Va, sigo pendiente de la tabla para armar el mapa. Pégala aquí en texto, una línea por cuenta:', + CARTERA_FORMATO_TABLA, + ].join('\n'), +}; + +// Persona cliente activo / CFO: reúsa el helper beta (Andrés) y el perfil +// persona-first (QA y contactos nuevos). El owner JAMÁS entra a este flujo. +function ia360IsClienteActivoCartera(contact) { + if (!contact) return false; + if (isIa360ClienteActivoBetaContact(contact)) return true; + const cf = contact.custom_fields || {}; + const rel = cf?.ia360_persona_first?.classification?.relationship_context || ''; + const personaCtx = String(cf.persona_context || '').toLowerCase(); + const tags = Array.isArray(contact.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return rel === 'cliente_activo' + || personaCtx === 'cliente activo' + || tags.includes('persona:cliente_activo'); +} + +// Disparador del PASO 1: cartera/cobranza explícita, o "saldos" acompañado de +// señal de descuadre. Mantenerlo angosto: un tema no-cartera NO debe activar +// el flujo (gate del goal). +const IA360_CARTERA_TRIGGER_RE = /\b(cartera|cobranza|cuentas?\s+por\s+cobrar)\b/i; +const IA360_CARTERA_SALDOS_RE = /\bsaldos?\b/i; +const IA360_CARTERA_DESCUADRE_RE = /no\s+cuadra|descuadr|incorrect|equivocad|diferenc|portal|\bmal\b/i; + +function ia360EsMensajeCartera(body) { + const t = String(body || ''); + return IA360_CARTERA_TRIGGER_RE.test(t) + || (IA360_CARTERA_SALDOS_RE.test(t) && IA360_CARTERA_DESCUADRE_RE.test(t)); +} + +// Parser de la tabla pegada en texto. Separadores: | ; tab. Coma solo si la +// línea no trae montos con coma de millares ("1,250,000"). Salta encabezados. +function parseCarteraTabla(text) { + const rows = []; + const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean); + for (const line of lines) { + let parts = null; + if (/[|;\t]/.test(line)) parts = line.split(/[|;\t]/); + else if (line.includes(',') && !/\d,\d{3}/.test(line)) parts = line.split(','); + if (!parts) continue; + parts = parts.map(p => p.trim()).filter(p => p !== ''); + if (parts.length < 4) continue; + const low = line.toLowerCase(); + if (/cliente|cuenta/.test(low) && /saldo/.test(low)) continue; // encabezado + rows.push({ + cuenta: parts[0], + saldo_portal: parts[1], + saldo_correcto: parts[2], + fecha_corte: parts[3], + responsable: parts[4] || 'por confirmar', + }); + } + return rows; +} + +function carteraMonto(s) { + const limpio = String(s || '').replace(/[^0-9.\-]/g, ''); + if (!limpio || limpio === '-' || limpio === '.') return null; + const n = Number(limpio); + return Number.isFinite(n) ? n : null; +} + +function carteraFormatoMonto(n) { + if (n === null || !Number.isFinite(n)) return null; + const negativo = n < 0; + const [ent, dec] = Math.abs(n).toFixed(2).split('.'); + return `${negativo ? '-' : ''}$${ent.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${dec}`; +} + +// Mapa estructurado: cuenta → saldo portal → saldo correcto → diferencia → +// fecha de corte → responsable → siguiente acción. +function buildCarteraMapa(rows) { + const bloques = []; + let diferenciaTotal = 0; + let cuentasConDescuadre = 0; + rows.forEach((r, i) => { + const portal = carteraMonto(r.saldo_portal); + const correcto = carteraMonto(r.saldo_correcto); + const dif = portal !== null && correcto !== null ? correcto - portal : null; + if (dif !== null) { + diferenciaTotal += dif; + if (dif !== 0) cuentasConDescuadre += 1; + } + bloques.push([ + `${i + 1}) Cuenta: ${r.cuenta}`, + ` - Saldo en portal: ${carteraFormatoMonto(portal) || r.saldo_portal}`, + ` - Saldo correcto: ${carteraFormatoMonto(correcto) || r.saldo_correcto}`, + ` - Diferencia: ${dif !== null ? carteraFormatoMonto(dif) : 'por calcular'}`, + ` - Fecha de corte: ${r.fecha_corte}`, + ` - Responsable: ${r.responsable}`, + ` - Siguiente acción: corregir el saldo en el portal y confirmarlo con ${r.responsable} antes del próximo corte.`, + ].join('\n')); + }); + const texto = [ + '*Mapa de cartera — saldos por corregir*', + '', + bloques.join('\n\n'), + '', + `Cuentas con descuadre: ${cuentasConDescuadre} de ${rows.length} · Diferencia acumulada: ${carteraFormatoMonto(diferenciaTotal) || 'por calcular'}`, + '', + 'Ya quedó registrado para Alek con el detalle completo. Cuando el portal refleje los saldos correctos, este mapa sirve para confirmarlo cuenta por cuenta.', + ].join('\n'); + return { texto, diferenciaTotal, cuentasConDescuadre }; +} + +// Movimiento de deal dedicado a Pipeline 7 (clon del patrón syncRevenueOsDeal: +// create-or-move, solo hacia adelante por posición; NO toca otros pipelines). +async function syncCarteraChampionsDeal({ record, targetStageName, notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [CHAMPIONS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const title = `IA360 · ${contactName} · Quick win cartera`; + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name, title }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + notes = $3, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $4`, + [finalStageId, shouldMove ? finalStatus : existing.status, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name, title: existing.title }; +} + +// Media (imagen/documento) durante esperando_tabla → pedir la versión en texto. +// El bot no descarga ni interpreta el archivo; solo guía al contacto. +async function handleCarteraMediaInbound(record) { + try { + if (!record || record.direction !== 'incoming') return false; + if (record.message_type !== 'image' && record.message_type !== 'document') return false; + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || !ia360IsClienteActivoCartera(contact)) return false; + if ((contact.custom_fields?.ia360_cartera_state || '') !== 'esperando_tabla') return false; + await enqueueIa360Text({ record, label: 'ia360_cartera_pide_texto', body: IA360_CARTERA_COPY.pideTexto }); + return true; + } catch (err) { + console.error('[cartera] media handler error (no route):', err.message); + return false; + } +} + +// Readout al owner tras entregar el mapa (PASO 2). ownerBudget=false: un quick +// win entregado siempre se reporta. +function buildCarteraOwnerReadout({ record, contactName, deal, mapa, rows }) { + return [ + `IA360 · Quick win cartera — ${contactName || 'contacto'} (${maskIa360Number(record.contact_number)})`, + '', + `El contacto entregó su tabla de cartera (${rows.length} ${rows.length === 1 ? 'cuenta' : 'cuentas'}) y le devolví el mapa estructurado.`, + `- Cuentas con descuadre: ${mapa.cuentasConDescuadre} · Diferencia acumulada: ${carteraFormatoMonto(mapa.diferenciaTotal) || 'por calcular'}`, + deal ? `- Deal: «${deal.title || 'sin título'}» → ${deal.stage} (P7 Champions).` : '- Deal: no se encontró deal en P7 (revisar).', + '- Mapa encolado a ia360_docs_sync (destino AlekContenido).', + '', + 'No envié pitch ni agenda; el flujo quedó en modo quick win.', + ].join('\n'); +} + +// PASO 1 + PASO 2 — texto libre. Va DESPUÉS de Revenue OS y ANTES del agente +// genérico en el dispatch; devuelve true para CORTAR el embudo (guardrail: el +// agente no debe responder encima ni empujar agenda). +async function handleCarteraFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || !ia360IsClienteActivoCartera(contact)) return false; + const state = contact.custom_fields?.ia360_cartera_state || ''; + + // PASO 2 — esperando la tabla. + if (state === 'esperando_tabla') { + const rows = parseCarteraTabla(body); + if (rows.length > 0) { + const mapa = buildCarteraMapa(rows); + const deal = await syncCarteraChampionsDeal({ + record, + targetStageName: CARTERA_STAGE_QUICKWIN, + notes: `PASO 2 mapa de cartera: tabla recibida (${rows.length} cuentas). Quick win entregado.\nTabla original:\n${body}\n\n${mapa.texto}`, + }).catch(e => { console.error('[cartera] deal quick win:', e.message); return null; }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['cartera-quickwin-entregado'], + customFields: { + ia360_cartera_state: 'mapa_entregado', + ia360_cartera_mapa_at: new Date().toISOString(), + ia360_cartera_cuentas: rows.length, + ia360_cartera_tabla_raw: body, + }, + }).catch(e => console.error('[cartera] estado mapa_entregado:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_cartera_mapa', body: mapa.texto }); + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) + VALUES (NULL, $1, $2, 'AlekContenido')`, + [ + `Mapa de cartera — ${contact.name || record.contact_number} (${new Date().toISOString().slice(0, 10)})`, + `${mapa.texto}\n\n---\nTabla original pegada por el contacto:\n${body}`, + ] + ).catch(e => console.error('[cartera] docs_sync:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_cartera_readout', + body: buildCarteraOwnerReadout({ record, contactName: contact.name, deal, mapa, rows }), + targetContact: record.contact_number, + }).catch(e => console.error('[cartera] owner readout:', e.message)); + return true; + } + // Sin tabla todavía: si insiste en el tema, recordamos el formato; si + // habla de otra cosa, el agente genérico responde (respuesta siempre útil). + if (ia360EsMensajeCartera(body)) { + await enqueueIa360Text({ record, label: 'ia360_cartera_formato', body: IA360_CARTERA_COPY.recordatorioFormato }); + return true; + } + return false; + } + + // PASO 1 — disparo del flujo (solo tema cartera; gate del goal). + if (state !== 'mapa_entregado' && ia360EsMensajeCartera(body)) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['cartera-quickwin'], + customFields: { + ia360_cartera_state: 'esperando_tabla', + ia360_cartera_dolor: body, + ia360_cartera_paso1_at: new Date().toISOString(), + }, + }); + await syncCarteraChampionsDeal({ + record, + targetStageName: CARTERA_STAGE_VALIDACION, + notes: `PASO 1 mapa de cartera: el contacto reportó saldos que no cuadran. Mensaje: ${body}`, + }).catch(e => console.error('[cartera] deal paso 1:', e.message)); + await enqueueIa360Text({ record, label: 'ia360_cartera_paso1', body: IA360_CARTERA_COPY.paso1 }); + return true; + } + return false; + } catch (err) { + console.error('[cartera] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record, vars = null) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + // G-COLD: para índices distintos de '1', vars (valor real del flujo, p.ej. + // quien_intro) tiene prioridad SOLO si trae contenido; si no, se conserva + // el fallback samples[k] || ' ' de los flujos existentes. + parameters: indexes.map(k => { + if (k === '1') return { type: 'text', text: firstNameForTemplate(record) }; + const v = vars?.[k]; + const hasVar = v != null && String(v).trim() !== ''; + return { type: 'text', text: hasVar ? String(v) : String(samples[k] || ' ') }; + }), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null, vars = null, allowTextFallback = true }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record, vars); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + // G-COLD: fuera de ventana el fallback a texto libre está PROHIBIDO + // (allowTextFallback:false): Meta lo rechazaría y aquí se reportaría un + // éxito falso; además renderiza con samples, no con vars reales. + if (!allowTextFallback) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> sin fallback (allowTextFallback=false)`); + return { ok: false, status: 'template_invalid', error: v.errors.join('; ') }; + } + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + step2: { + si_pregunta: 'Va la pregunta: si este mensaje te hubiera llegado sin conocer a Alek, ¿se entiende qué es IA360 y qué puedo y no puedo hacer como IA, o hay algo que te haría desconfiar? Dímelo con toda franqueza; para eso es esta prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, + metaTemplateName: 'ia360_beta_architectura', // G-COLD: template frío con los mismos botones del opener + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_architectura:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_architectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_architectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + step2: { + si_pregunta: 'Gracias. ¿Cómo se siente recibir un mensaje así de una IA: natural, raro o invasivo? Lo que me digas se lo paso a Alek tal cual, sin suavizarlo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, + metaTemplateName: 'ia360_beta_feedback', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_feedback:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_feedback:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_feedback:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + step2: { + si_a_ver: 'Va. Pregúntame algo que Alek y tú hayan platicado o trabajado antes, y te digo qué tengo registrado. Tú pones la prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, + metaTemplateName: 'ia360_beta_memoria', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_memoria:si_a_ver', title: 'Sí, a ver' }, + { id: 'seq_beta_memoria:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_memoria:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + step2: { + pregunta: '¿Qué te contó la persona que nos presentó sobre lo que hace Alek, y qué te llamó la atención para aceptar la introducción? Con eso evitamos mandarte algo fuera de lugar.', + }, + draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + // G-COLD: el {{2}} del template es quien_intro; en frío se exige el dato + // antes de aprobar (ver handleIa360OwnerApproveSend). + metaTemplateName: 'ia360_referido_contexto', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_contexto:pregunta', title: 'Hazme una pregunta' }, + { id: 'seq_referido_contexto:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_contexto:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + step2: { + si_cuentame: 'Va la versión completa en corto: IA360 conecta WhatsApp, CRM, agenda y memoria de clientes para que el seguimiento no dependa de la memoria de nadie. ¿En tu operación dónde se cae más el seguimiento hoy: mensajes, CRM o agenda?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, + metaTemplateName: 'ia360_referido_oneliner', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_oneliner:si_cuentame', title: 'Sí, cuéntame más' }, + { id: 'seq_referido_oneliner:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_oneliner:ahora_no', title: 'Por ahora no' }, + ], + }, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + step2: { + pregunta: 'Claro, pregunta con confianza: qué hace IA360, cómo trabaja Alek o qué implicaría la llamada. Te respondo aquí mismo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, + metaTemplateName: 'ia360_referido_permiso_agenda_v2', + // G-COLD: el template v2 ya trae los mismos botones del opener; el alias + // mapea su afirmativo a la rama de horarios. + templateAliasOption: 'horarios', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_permiso_agenda:horarios', title: 'Proponme horarios' }, + { id: 'seq_referido_permiso_agenda:pregunta', title: 'Primero una pregunta' }, + { id: 'seq_referido_permiso_agenda:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + step2: { + si_pregunta: 'Gracias. ¿Qué tipo de clientes atiendes hoy y dónde los ves sufrir más: WhatsApp desordenado, CRM sin seguimiento o procesos repetidos a mano? Con eso mapeamos el fit.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, + metaTemplateName: 'ia360_aliado_mapa_colaboracion_v2', // G-COLD: v2 con botones QUICK_REPLY del opener + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_mapa_colaboracion:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_mapa_colaboracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_mapa_colaboracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + step2: { + si_pregunta: 'Va: cuando un cliente tuyo ya necesita ordenar WhatsApp, CRM o seguimiento, ¿qué señales lo delatan primero? Con eso definimos juntos a quién sí presentarle IA360.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, + metaTemplateName: 'ia360_aliado_criterios_fit', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_criterios_fit:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_criterios_fit:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_criterios_fit:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + step2: { + si_comparte: 'Va el caso NDA-safe en corto: una empresa de servicios perdía seguimiento entre WhatsApp y su CRM; con IA360 cada conversación queda registrada, el pipeline se mueve solo y el dueño revisa su semana en un tablero. ¿Le haría sentido a alguno de tus clientes?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, + metaTemplateName: 'ia360_aliado_caso_reventa', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_caso_reventa:si_comparte', title: 'Sí, compártelo' }, + { id: 'seq_aliado_caso_reventa:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_caso_reventa:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + step2: { + si_cuento: 'Te leo. Cuéntame el avance, la fricción o el pendiente con el detalle que quieras; se lo dejo a Alek con contexto hoy mismo.', + todo_bien: 'Qué bueno. Le paso a Alek que todo va en orden. Cualquier cosa que surja, me escribes por aquí y se lo pongo enfrente.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, + metaTemplateName: 'ia360_cliente_readout', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_readout:si_cuento', title: 'Sí, te cuento' }, + { id: 'seq_cliente_readout:todo_bien', title: 'Todo va bien' }, + { id: 'seq_cliente_readout:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + step2: { + hay_tema: 'Cuéntame el tema con el detalle que quieras; se lo paso a Alek hoy mismo con prioridad para que no se quede atorado.', + todo_orden: 'Perfecto, me da gusto. Le confirmo a Alek que no hay pendientes de su lado. Aquí sigo si surge algo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, + metaTemplateName: 'ia360_cliente_soporte', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_soporte:hay_tema', title: 'Sí, hay un tema' }, + { id: 'seq_cliente_soporte:todo_orden', title: 'Todo en orden' }, + { id: 'seq_cliente_soporte:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te escribo de su parte porque tú y Alek ya tienen un proyecto andando, y Alek quiere ubicar dónde estaría el siguiente paso con más impacto, sin empujarte nada fuera de tiempo. De estas áreas, ¿cuál te quita más tiempo hoy?`, + requiresLiveDeal: true, + metaTemplateName: 'ia360_cliente_expansion', // G-COLD: en frío sale como QUICK_REPLY (la lista vive en el flujo caliente) + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cliente_expansion:whatsapp', title: 'WhatsApp y mensajes' }, + { id: 'seq_cliente_expansion:crm', title: 'CRM y clientes' }, + { id: 'seq_cliente_expansion:datos', title: 'Datos y reportes' }, + { id: 'seq_cliente_expansion:agenda', title: 'Agenda y citas' }, + { id: 'seq_cliente_expansion:seguimiento', title: 'Seguimiento de ventas' }, + { id: 'seq_cliente_expansion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte antes de mandarte una demo genérica: prefiere ubicar primero dónde habría valor real para tu operación. De estas áreas, ¿dónde sientes el cuello de botella que más mueve la aguja?`, + metaTemplateName: 'ia360_sponsor_diagnostico', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_sponsor_diagnostico:operacion', title: 'Operación' }, + { id: 'seq_sponsor_diagnostico:ventas', title: 'Ventas' }, + { id: 'seq_sponsor_diagnostico:datos', title: 'Datos y reportes' }, + { id: 'seq_sponsor_diagnostico:seguimiento', title: 'Seguimiento' }, + { id: 'seq_sponsor_diagnostico:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?`, + metaTemplateName: 'ia360_sponsor_fuga_valor', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir fuga', + options: [ + { id: 'seq_sponsor_fuga_valor:tiempo', title: 'Tiempo perdido' }, + { id: 'seq_sponsor_fuga_valor:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_sponsor_fuga_valor:datos', title: 'Datos poco confiables' }, + { id: 'seq_sponsor_fuga_valor:decisiones', title: 'Decisiones lentas' }, + { id: 'seq_sponsor_fuga_valor:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + step2: { + si_manda: 'Va el caso en corto: una operación que dependía de WhatsApp y Excel perdía seguimiento y visibilidad; con IA360 los mensajes alimentan el CRM, el pipeline se mueve solo y la dirección revisa su semana en un tablero. Si quieres, Alek te aterriza el paralelo con tu operación en una llamada corta.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, + metaTemplateName: 'ia360_sponsor_caso_ndasafe', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_sponsor_caso_ndasafe:si_manda', title: 'Sí, mándalo' }, + { id: 'seq_sponsor_caso_ndasafe:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_sponsor_caso_ndasafe:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja ayudando a equipos comerciales y casi siempre el problema aparece en uno de tres lugares. En tu equipo, ¿cuál duele más hoy?`, + metaTemplateName: 'ia360_comercial_pipeline', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_pipeline:leads', title: 'Leads que no llegan' }, + { id: 'seq_comercial_pipeline:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_comercial_pipeline:contexto', title: 'WhatsApp sin contexto' }, + { id: 'seq_comercial_pipeline:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy?`, + metaTemplateName: 'ia360_comercial_wa_crm', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_wa_crm:historial', title: 'Historial de clientes' }, + { id: 'seq_comercial_wa_crm:seguimiento', title: 'Seguimiento' }, + { id: 'seq_comercial_wa_crm:prioridad', title: 'Prioridad de leads' }, + { id: 'seq_comercial_wa_crm:datos', title: 'Datos para decidir' }, + { id: 'seq_comercial_wa_crm:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?`, + metaTemplateName: 'ia360_comercial_motor_prospeccion', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_motor_prospeccion:segmento', title: 'Segmento claro' }, + { id: 'seq_comercial_motor_prospeccion:mensaje', title: 'Mensaje repetible' }, + { id: 'seq_comercial_motor_prospeccion:seguimiento', title: 'Seguimiento medible' }, + { id: 'seq_comercial_motor_prospeccion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja con equipos de finanzas que terminan operando a mano porque no pueden confiar rápido en sus datos. En tu caso, ¿dónde está el mayor dolor hoy?`, + metaTemplateName: 'ia360_cfo_control', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cfo_control:cartera', title: 'Cartera' }, + { id: 'seq_cfo_control:comisiones', title: 'Comisiones' }, + { id: 'seq_cfo_control:reportes', title: 'Reportes' }, + { id: 'seq_cfo_control:conciliacion', title: 'Conciliación' }, + { id: 'seq_cfo_control:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + step2: { + respondo: 'Te leo. Cuéntame qué información te cuesta más tener confiable y a tiempo (cartera, cobranza, reportes), y se la paso a Alek aterrizada.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + metaTemplateName: 'ia360_cfo_cartera_datos', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cfo_cartera_datos:respondo', title: 'Te respondo aquí' }, + { id: 'seq_cfo_cartera_datos:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_cfo_cartera_datos:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + metaTemplateName: 'ia360_cfo_comisiones', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_cfo_comisiones:reglas', title: 'Reglas manuales' }, + { id: 'seq_cfo_comisiones:excepciones', title: 'Excepciones' }, + { id: 'seq_cfo_comisiones:datos', title: 'Datos que no cuadran' }, + { id: 'seq_cfo_comisiones:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + step2: { + mapa: 'Va el mapa corto: WhatsApp Cloud API → ForgeChat (bandeja y reglas) → n8n (orquestación) → CRM y memoria por contacto. Todo con permisos mínimos, trazabilidad de cada mensaje y aprobación humana antes de cualquier envío sensible. Si quieres el detalle técnico completo, Alek te lo manda directo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, + metaTemplateName: 'ia360_tecnico_arquitectura', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_arquitectura:mapa', title: 'Mándame el mapa' }, + { id: 'seq_tecnico_arquitectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_arquitectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada?`, + metaTemplateName: 'ia360_tecnico_rollback', // G-COLD + openerOptions: { + kind: 'list', + button: 'Elegir riesgo', + options: [ + { id: 'seq_tecnico_rollback:permisos', title: 'Permisos' }, + { id: 'seq_tecnico_rollback:datos', title: 'Datos' }, + { id: 'seq_tecnico_rollback:trazabilidad', title: 'Trazabilidad' }, + { id: 'seq_tecnico_rollback:reversibilidad', title: 'Reversibilidad' }, + { id: 'seq_tecnico_rollback:dependencia', title: 'Dependencia operativa' }, + { id: 'seq_tecnico_rollback:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + step2: { + respondo: 'Te leo. Dime qué condición tendría que cumplirse para que la prueba te parezca segura (permisos, alcance, datos, reversibilidad) y la registro tal cual para Alek.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, + metaTemplateName: 'ia360_tecnico_integracion', // G-COLD + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_integracion:respondo', title: 'Te respondo aquí' }, + { id: 'seq_tecnico_integracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_integracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +// Openers v2: saludo con primer nombre (D9). Limpia el sufijo de QA y toma el +// primer token; si no hay nada usable devuelve el valor original. +function ia360FirstNameFrom(name) { + const raw = String(name || '').trim().replace(/\s+WhatsApp IA360$/i, '').trim(); + return raw.split(/\s+/).filter(Boolean)[0] || raw; +} + +// Openers v2: arma el objeto `interactive` de un opener desde sequence.openerOptions +// (kind 'buttons' ≤3 opciones, kind 'list' 4+). Sin header ni footer: el copy +// aprobado por Alek va fiel en body.text. Devuelve null si la secuencia no tiene +// openerOptions (esas siguen saliendo como texto plano). +function buildIa360OpenerInteractive({ sequence, bodyText }) { + const opts = sequence && sequence.openerOptions; + if (!opts || !Array.isArray(opts.options) || !opts.options.length) return null; + if (opts.kind === 'list') { + return { + type: 'list', + body: { text: bodyText }, + action: { + button: opts.button || 'Elegir', + sections: [{ + title: 'Opciones', + rows: opts.options.map(o => ({ id: o.id, title: o.title, ...(o.description ? { description: o.description } : {}) })), + }], + }, + }; + } + return { + type: 'button', + body: { text: bodyText }, + action: { + buttons: opts.options.slice(0, 3).map(o => ({ type: 'reply', reply: { id: o.id, title: o.title } })), + }, + }; +} + +// ── G-C: ruteo real de respuestas seq_* (openers v2) ───────────────────────── +// Un botón/fila `seq_:` del catálogo persona-first SIEMPRE +// recibe un siguiente paso real: paso 2 definido en el catálogo (`step2`), +// manejo semántico compartido (alek_directo / ahora_no / horarios) o acuse +// específico con eco de la elección + aviso al owner con la nextAction de la +// secuencia. Devuelve true si lo manejó; false SOLO para ids seq_* que no están +// en el catálogo (esos sí caen al fallback global, porque son inválidos). +async function handleIa360SequenceReply({ record, replyId, contact = null }) { + const m = /^seq_([a-z0-9_]+):([a-z0-9_]+)$/.exec(String(replyId || '').trim().toLowerCase()); + if (!m) return false; + const sequenceId = m[1]; + const optionKey = m[2]; + const found = findIa360SequenceFlow(sequenceId); + if (!found) return false; + const { sequence } = found; + const option = (sequence.openerOptions?.options || []) + .find(o => String(o.id).toLowerCase() === `seq_${sequenceId}:${optionKey}`); + if (!option) return false; + try { + const ctx = contact || await loadIa360ContactContext(record).catch(() => null); + const cf = ctx?.custom_fields || {}; + const contactName = ctx?.name || record.contact_name || record.contact_number; + const safeName = sanitizeIa360IntroName(contactName) || record.contact_number; + const nowIso = new Date().toISOString(); + + // Guard de estado (paridad con el router 100M): si la conversación ya avanzó + // a agenda/reunión/handoff humano, un botón seq_* de un opener viejo NO mueve + // el deal hacia atrás; responde continuidad y el owner se entera del tap. + const guard = await ia360HundredMAdvancedGuard(record); + if (guard.advanced) { + await enqueueIa360Text({ record, label: `ia360_seq_continuity_${sequenceId}`, body: guard.body }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_stale_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) tocó "${option.title}" de un opener viejo ("${sequence.label}"), pero su proceso ya va más adelante. No moví nada; le respondí con continuidad.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] stale notify:', e.message)); + return true; + } + + // Dedupe de doble tap del contacto: misma secuencia+opción ya registrada → + // continuidad corta, sin re-registro ni avisos duplicados al owner. + const prev = cf.ia360_seq_last_response || null; + if (prev && prev.sequence === sequenceId && prev.option === optionKey) { + await enqueueIa360Text({ + record, + label: `ia360_seq_dup_${sequenceId}`, + body: `Ya tengo registrada tu respuesta "${option.title}" y Alek ya tiene el contexto. Quedo al pendiente; cualquier cosa me escribes por aquí.`, + }); + return true; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-seq-respuesta', `seq-${sequenceId}`], + customFields: { + ia360_seq_last_response: { sequence: sequenceId, option: optionKey, title: option.title, at: nowIso }, + ia360_ultima_respuesta: option.title, + ultimo_cta_enviado: `ia360_seq_reply_${sequenceId}_${optionKey}`, + }, + }).catch(e => console.error('[ia360-seq] merge state:', e.message)); + + const notifyOwner = (detalle) => sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_reply_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) respondió "${option.title}" al opener "${sequence.label}". ${detalle}`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] notify owner:', e.message)); + + // 1) Salida directa con Alek. + if (optionKey === 'alek_directo') { + await enqueueIa360Text({ + record, + label: `ia360_seq_alek_directo_${sequenceId}`, + body: 'Perfecto, le aviso a Alek ahora mismo para que te escriba directo. Gracias por responder.', + }); + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió hablar directo con Alek.`, + }).catch(e => console.error('[ia360-seq] deal alek_directo:', e.message)); + await notifyOwner('Pidió que le escribas TÚ directo. Deal en "Requiere Alek".'); + return true; + } + + // 2) Cierre suave → nutrición. + if (optionKey === 'ahora_no') { + await enqueueIa360Text({ + record, + label: `ia360_seq_ahora_no_${sequenceId}`, + body: 'De acuerdo, no te insisto. Si más adelante quieres retomarlo, me escribes por aquí y seguimos donde lo dejamos.', + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: {}, + }).catch(e => console.error('[ia360-seq] tag nutricion:', e.message)); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: ahora no. Pasa a nutrición suave.`, + }).catch(e => console.error('[ia360-seq] deal ahora_no:', e.message)); + await notifyOwner('Respondió que ahora no; queda en nutrición suave.'); + return true; + } + + // 3) Agenda con permiso (referido_permiso_agenda:horarios). + if (optionKey === 'horarios') { + await enqueueIa360Interactive({ + record, + label: `ia360_seq_horarios_${sequenceId}`, + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + body: { text: 'Perfecto. ¿Qué ventana te acomoda mejor para la llamada con Alek?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió horarios. Deal a "Agenda en proceso".`, + }).catch(e => console.error('[ia360-seq] deal horarios:', e.message)); + await notifyOwner('Pidió horarios para una llamada contigo. Deal en "Agenda en proceso".'); + return true; + } + + // 4) Paso 2 definido en el catálogo. + const step2 = sequence.step2 && sequence.step2[optionKey]; + if (step2) { + await enqueueIa360Text({ + record, + label: `ia360_seq_step2_${sequenceId}_${optionKey}`, + body: step2, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Paso 2 de la secuencia enviado.`, + }).catch(e => console.error('[ia360-seq] deal step2:', e.message)); + await notifyOwner(`Le envié el paso 2 de la secuencia. Next action sugerida: ${sequence.nextAction}`); + return true; + } + + // 5) Sin paso 2 en el catálogo (temas de lista): acuse específico con eco de + // la elección + aviso al owner con la respuesta y la next action sugerida. + await enqueueIa360Text({ + record, + label: `ia360_seq_ack_${sequenceId}_${optionKey}`, + body: `Gracias, registré tu respuesta: "${option.title}". Le paso este contexto a Alek para que el siguiente paso vaya directo a eso, sin rodeos. Te escribe él con una propuesta concreta.`, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Acuse enviado; siguiente paso con Alek.`, + }).catch(e => console.error('[ia360-seq] deal ack:', e.message)); + await notifyOwner(`Next action sugerida: ${sequence.nextAction}`); + return true; + } catch (err) { + console.error('[ia360-seq] reply error:', err.message); + // Nunca mudo: acuse mínimo aunque el registro haya fallado. + await enqueueIa360Text({ + record, + label: 'ia360_seq_ack_error', + body: 'Recibí tu respuesta y ya se la pasé a Alek. Te escribe él en corto.', + }).catch(() => {}); + return true; + } +} + +// ── G-C: CTAs únicos — alias de botones de template (quick replies de texto) ── +// Los templates fríos (p. ej. ia360_referido_apertura = template 41, +// ia360_aliado_mapa_colaboracion = template 43) llegan con button.payload = +// TEXTO del botón, no un id estructurado, por lo que "Sí, cuéntame" era ambiguo +// entre Revenue OS y Referidos. Revenue OS se resuelve ANTES en el dispatch +// (handleRevenueOsButton, gateado por ia360_revenue_state); si no era suyo, este +// alias traduce el texto al id seq_* ÚNICO de la secuencia persona-first cuyo +// opener realmente se le envió al contacto (pf.sequence_candidate.id + pf.send). +const IA360_SEQ_ALIAS_NEGATIVE = new Set(['ahora no', 'por ahora no', 'no por ahora']); +const IA360_SEQ_ALIAS_HANDOFF = new Set(['que me escriba alek', 'hablar con alek']); +// Solo frases genuinamente afirmativas. Los títulos exactos del catálogo +// ("Proponme horarios", "Te respondo aquí", "Hazme una pregunta", etc.) se +// resuelven ANTES por match exacto de título (paso 1 del resolver), no por +// semántica: ponerlos aquí fabricaría elecciones equivocadas. Estos sets solo +// aplican cuando el texto del botón NO coincide con ningún título del opener. +const IA360_SEQ_ALIAS_AFFIRMATIVE = new Set([ + 'sí, cuéntame', 'si, cuéntame', 'sí, cuentame', 'si, cuentame', + 'sí, cuéntame más', 'si, cuentame mas', + 'sí, pregúntame', 'si, preguntame', + 'sí, mándalo', 'si, mandalo', + 'sí, compártelo', 'si, compartelo', + 'sí, a ver', 'si, a ver', + 'sí, te cuento', 'si, te cuento', + 'sí, hay un tema', 'si, hay un tema', + 'me interesa', 'sí, me interesa', 'si, me interesa', +]); + +function resolveIa360TemplateButtonAlias({ replyId, contact }) { + const key = String(replyId || '').trim().toLowerCase(); + if (!key || key.startsWith('seq_')) return null; + const pf = contact?.custom_fields?.ia360_persona_first; + const seqId = pf?.sequence_candidate?.id; + if (!seqId || !pf?.send?.sent_at) return null; // solo si su opener realmente salió + const found = findIa360SequenceFlow(seqId); + if (!found) return null; + const opts = found.sequence.openerOptions?.options || []; + // 1) Match exacto por título visible del botón — ANTES del gate semántico: + // los botones legítimos de los templates fríos ("Hazme una pregunta", + // "Te respondo aquí", "Todo va bien", "Mándame el mapa", "Proponme + // horarios"...) rutean por su propio título aunque no estén en los sets. + const byTitle = opts.find(o => String(o.title).trim().toLowerCase() === key); + if (byTitle) return String(byTitle.id).toLowerCase(); + // 2) Gate semántico, solo si no hubo match por título. + const isNeg = IA360_SEQ_ALIAS_NEGATIVE.has(key); + const isHand = IA360_SEQ_ALIAS_HANDOFF.has(key); + const isAff = IA360_SEQ_ALIAS_AFFIRMATIVE.has(key); + if (!isNeg && !isHand && !isAff) return null; + // 3) Por semántica: negativo → ahora_no; handoff → alek_directo; afirmativo → + // la primera opción que no sea ninguna de las dos (el camino afirmativo). + const bySuffix = (suffix) => opts.find(o => String(o.id).toLowerCase().endsWith(`:${suffix}`)); + if (isNeg) { const o = bySuffix('ahora_no'); return o ? String(o.id).toLowerCase() : null; } + if (isHand) { const o = bySuffix('alek_directo'); return o ? String(o.id).toLowerCase() : null; } + // Afirmativo SOLO cuando es inequívoco: la secuencia declara su opción de + // template (templateAliasOption) o existe exactamente UNA opción no terminal. + // Con varias opciones posibles (listas de temas) NO se fabrica una elección: + // se devuelve null y el fallback global acusa recibo y avisa al owner. + if (found.sequence.templateAliasOption) { + const o = bySuffix(found.sequence.templateAliasOption); + if (o) return String(o.id).toLowerCase(); + } + const nonTerminal = opts.filter(o => { + const id = String(o.id).toLowerCase(); + return !id.endsWith(':ahora_no') && !id.endsWith(':alek_directo'); + }); + return nonTerminal.length === 1 ? String(nonTerminal[0].id).toLowerCase() : null; +} + +// ── G-C: anti-loop del router 100M ─────────────────────────────────────────── +// Nodos que en las pruebas reales generaron ciclos (doc 2026-06-10, chat_history +// 1068-1079 y 1135-1142): exploración, mecanismos, mapa y ejemplo. Una visita +// repetida ya no reenvía el bloque completo: responde una versión condensada con +// salidas terminales (agendar / llamada / más adelante). +const IA360_100M_LOOP_PRONE = new Set([ + 'explorando', + 'mecanismo-whatsapp-crm', + 'mecanismo-erp-bi', + 'mecanismo-agentic-followup', + 'mapa-30-60-90-solicitado', + 'ejemplo-solicitado', +]); +// Etapas donde la conversación ya avanzó a agenda/handoff humano: un botón 100M +// de un mensaje viejo NO debe reabrir la rama (guard de estado/versión). +const IA360_100M_ADVANCED_STAGES = new Set(['Agenda en proceso', 'Reunión agendada', 'Requiere Alek']); + +async function ia360HundredMAdvancedGuard(record) { + const out = { advanced: false, body: '', visited: {}, visitedOk: false }; + try { + const contact = await loadIa360ContactContext(record).catch(() => null); + const cf = contact?.custom_fields || {}; + if (contact) { + out.visited = (cf.ia360_100m_visited && typeof cf.ia360_100m_visited === 'object') ? cf.ia360_100m_visited : {}; + out.visitedOk = true; // lectura confiable: se puede escribir sin pisar el mapa + } + // Solo reuniones FUTURAS cuentan como "en curso": el cache crudo ia360_bookings + // conserva citas pasadas y atraparía al contacto para siempre. + const bookings = await loadIa360BookingsForList(record.contact_number).catch(() => []); + const hasBooking = Array.isArray(bookings) && bookings.length > 0; + let stageName = ''; + const deal = await getActiveNonTerminalIa360Deal(record).catch(() => null); + if (deal) stageName = deal.stage_name || ''; + if (hasBooking || IA360_100M_ADVANCED_STAGES.has(stageName)) { + out.advanced = true; + out.body = (hasBooking || stageName === 'Reunión agendada') + ? 'Vi tu respuesta, pero tu proceso ya va más adelante: tienes una reunión en curso con Alek. Sigo con eso para no regresarte al inicio. Si quieres mover la reunión o retomar otro tema, dímelo por aquí.' + : 'Vi tu respuesta a un mensaje anterior, pero tu proceso ya va más adelante: estamos en la parte de agenda con Alek. Sigo con eso para no darte vueltas; si quieres retomar otro tema, dímelo por aquí y lo vemos.'; + } + } catch (err) { + console.error('[ia360-100m] advanced guard:', err.message); + } + return out; +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + // Openers v2: saludo con primer nombre (D9) + quién hizo la introducción (D6). + const draftName = ia360FirstNameFrom(name); + const quienIntro = String(customFields.quien_intro || '').trim() || null; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name: draftName, quienIntro }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + ...(sequence.openerOptions && Array.isArray(sequence.openerOptions.options) + ? ['', `Opciones del mensaje (${sequence.openerOptions.kind === 'list' ? 'lista' : 'botones'}): ${sequence.openerOptions.options.map(o => o.title).join(' | ')}`] + : []), + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +// G-D: pipelines donde un deal vivo habilita la jugada de expansión (D7). +const IA360_EXPANSION_PIPELINES = ['IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión']; + +// G-COLD: status real en Meta de los templates fríos, en UNA sola consulta. +// Si un nombre tiene varias filas gana APPROVED si alguna lo está (mismo +// criterio que enqueueIa360Template); si no, la más reciente por updated_at. +// En error de DB devuelve NULL (no {}): un {} significaría "template +// inexistente" y daría diagnóstico falso. Cada call site decide: la UX sale +// sin marcas (fail-open) y el envío se bloquea con aviso honesto (fail-closed). +async function loadIa360ColdTemplateStatuses(names) { + const list = [...new Set((names || []).filter(Boolean).map(String))]; + if (!list.length) return {}; + try { + const { rows } = await pool.query( + `SELECT name, status + FROM coexistence.message_templates + WHERE name = ANY($1) + ORDER BY updated_at DESC NULLS LAST, id DESC`, + [list] + ); + const out = {}; + for (const row of rows) { + if (String(out[row.name] || '').toUpperCase() === 'APPROVED') continue; + if (String(row.status || '').toUpperCase() === 'APPROVED') { out[row.name] = row.status; continue; } + if (!(row.name in out)) out[row.name] = row.status; // primera fila = más reciente + } + return out; + } catch (e) { + console.error('[ia360-cold] template statuses lookup:', e.message); + return null; + } +} + +// G-COLD: traduce el status de Meta a disponibilidad para envío en frío. +function ia360ColdAvailability(status) { + const s = String(status || '').toUpperCase(); + if (s === 'APPROVED') return { sendable: true, label: '✓ lista para frío' }; + if (['PENDING', 'SUBMITTED', 'IN_REVIEW'].includes(s)) return { sendable: false, label: 'template en revisión Meta' }; + if (s === 'REJECTED') return { sendable: false, label: 'template rechazado por Meta' }; + return { sendable: false, label: 'sin template frío' }; +} + +// G-COLD: ¿el contacto está fuera de la ventana de servicio de 24h? Mismo +// umbral 23.5h que approve-send. Error o cuenta sin resolver → {known:false} +// (fail-open: la UX del selector nunca debe romperse por esta verificación). +async function ia360OutsideWindow24h({ record, targetContact }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) return { known: false, outside: false }; + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + return { known: true, outside: !(secs != null && secs < 23.5 * 3600) }; + } catch (e) { + console.error('[ia360-cold] window check:', e.message); + return { known: false, outside: false }; + } +} + +// G-D: señales reales del contacto para el ranker del selector de secuencias. +// Cada consulta tiene su propio try/catch (fail-open): si la DB falla, esa señal +// queda en null y el selector sale con el orden default — nunca mudo. +// OJO: ia360_memory_* tiene doble keying en contact_wa_number (a veces la línea +// del bot, a veces el número del contacto); la llave confiable es contact_number. +async function gatherIa360ContactSignals({ waNumber, contactNumber }) { + const signals = { liveDeal: null, quienIntro: null, lastFact: null, lastEvent: null, lastIncomingAt: null }; + try { + const { rows } = await pool.query( + `SELECT d.title, p.name AS pipeline_name, s.name AS stage_name + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.contact_wa_number = $1 AND d.contact_number = $2 AND d.status = 'open' + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [waNumber, contactNumber] + ); + if (rows.length) signals.liveDeal = { title: rows[0].title, pipelineName: rows[0].pipeline_name, stageName: rows[0].stage_name }; + } catch (e) { console.error('[ia360-rank] deal lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro, custom_fields->>'referido_por' AS referido_por + FROM coexistence.contacts + WHERE wa_number = $1 AND contact_number = $2 + LIMIT 1`, + [waNumber, contactNumber] + ); + const quienIntro = String(rows[0]?.quien_intro || '').trim(); + if (quienIntro) { + signals.quienIntro = quienIntro; + } else { + // referido_por guarda el NÚMERO de quien compartió el vCard. Solo cuenta + // como introductor si NO es el owner, ni el bot, ni el propio contacto; + // y solo con un nombre presentable (no citamos números pelones). + const referidoPor = normalizePhone(String(rows[0]?.referido_por || '').trim()); + if (referidoPor && referidoPor !== IA360_OWNER_NUMBER && referidoPor !== normalizePhone(waNumber) && referidoPor !== normalizePhone(contactNumber)) { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number = $1 AND contact_number = $2 LIMIT 1`, + [waNumber, referidoPor] + ); + const introName = String(introRows[0]?.name || introRows[0]?.profile_name || '').trim(); + if (introName) signals.quienIntro = introName; + } + } + } catch (e) { console.error('[ia360-rank] quien_intro lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT COALESCE(recurring_pain, preference, objection, role) AS texto, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + AND COALESCE(recurring_pain, preference, objection, role) IS NOT NULL + ORDER BY last_seen_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastFact = { text: rows[0].texto, lastSeenAt: rows[0].last_seen_at }; + } catch (e) { console.error('[ia360-rank] facts lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT summary, created_at + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastEvent = { summary: rows[0].summary, createdAt: rows[0].created_at }; + } catch (e) { console.error('[ia360-rank] events lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT MAX(created_at) AS last_in + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 AND direction = 'incoming'`, + [waNumber, contactNumber] + ); + if (rows[0]?.last_in) signals.lastIncomingAt = rows[0].last_in; + } catch (e) { console.error('[ia360-rank] chat_history lookup:', e.message); } + return signals; +} + +// G-D: ranker rule-based (SIN LLM). Solo REORDENA las secuencias de la persona +// elegida; cada razón cita una señal que existe en la base. Sin señales que +// matcheen secuencias de esta persona → orden de catálogo y cero razones +// inventadas (honestidad del ranker). +function rankIa360Sequences({ flow, signals }) { + const scores = new Map(); + const reasons = new Map(); + const bump = (id, pts, reason) => { + scores.set(id, (scores.get(id) || 0) + pts); + if (reason && !reasons.has(id)) reasons.set(id, reason); + }; + const s = signals || {}; + if (s.liveDeal) { + const dealReason = `Deal vivo «${s.liveDeal.title}» en ${s.liveDeal.pipelineName}`; + if (IA360_EXPANSION_PIPELINES.includes(s.liveDeal.pipelineName)) { + bump('cliente_expansion', 35, dealReason); + bump('cliente_readout', 20, dealReason); + bump('cliente_soporte', 10, dealReason); + } else { + bump('cliente_readout', 30, dealReason); + bump('cliente_soporte', 20, dealReason); + } + } + if (s.quienIntro) { + const introReason = `Te lo presentó ${s.quienIntro}`; + bump('referido_contexto', 30, introReason); + bump('referido_permiso_agenda', 15, introReason); + bump('referido_oneliner', 10, introReason); + } + const memorySignal = s.lastEvent || s.lastFact; + if (memorySignal) { + // 40 y no más: "Sugerida: Memoria registrada: " + frag debe caber en los + // 72 chars de la description de Meta sin perder el final de la razón. + const frag = compactForWhatsApp(s.lastEvent ? s.lastEvent.summary : s.lastFact.text, 40); + const memReason = `Memoria registrada: ${frag}`; + bump('beta_memoria', 15, memReason); + bump('cliente_readout', 10, memReason); + } + const ordered = (flow.sequences || []) + .map((seq, idx) => ({ seq, idx, score: scores.get(seq.id) || 0 })) + .sort((a, b) => (b.score - a.score) || (a.idx - b.idx)); + const ranked = ordered.length > 0 && ordered[0].score > 0; + return { + ordered: ordered.map(o => o.seq), + suggestedId: ranked ? ordered[0].seq.id : null, + reasonFor: (id) => reasons.get(id) || null, + ranked, + }; +} + +// G-D: resumen de 2 líneas del contacto para el cuerpo de la tarjeta. Solo +// afirma lo que existe; sin señales devuelve una sola línea honesta. +function buildIa360ContactSummaryLines(signals) { + const s = signals || {}; + const fmtDate = (d) => { + try { return new Date(d).toISOString().slice(0, 10); } catch { return ''; } + }; + if (!s.liveDeal && !s.quienIntro && !s.lastFact && !s.lastEvent) { + return ['Aún no tengo señales registradas de este contacto (sin deal, sin memoria, sin introductor).']; + } + // Tope de 180: título/pipeline/etapa vienen de la base sin límite y el body + // del interactive de Meta admite 1024 como máximo — si se excede, la tarjeta + // se vuelve muda. Acotado aquí, el body completo queda siempre < 1024. + const line1 = s.liveDeal + ? compactForWhatsApp(`Deal vivo: «${s.liveDeal.title}» — ${s.liveDeal.pipelineName}${s.liveDeal.stageName ? ` / ${s.liveDeal.stageName}` : ''}.`, 180) + : 'Sin deal vivo registrado.'; + let line2; + if (s.lastEvent) { + const fecha = fmtDate(s.lastEvent.createdAt); + line2 = `Último evento${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastEvent.summary, 120)}`; + } else if (s.lastFact) { + const fecha = fmtDate(s.lastFact.lastSeenAt); + line2 = `Memoria${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastFact.text, 120)}`; + } else if (s.quienIntro) { + line2 = `Lo presentó: ${s.quienIntro}.`; + } else if (s.lastIncomingAt) { + line2 = `Última interacción: ${fmtDate(s.lastIncomingAt)}.`; + } else { + line2 = 'Sin memoria registrada todavía.'; + } + return [line1, line2]; +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + // G-D: ranker rule-based sobre señales reales — la sugerida primero con el + // porqué en su descripción; sin señales → orden de catálogo sin razones. + const signals = await gatherIa360ContactSignals({ waNumber: record.wa_number, contactNumber: targetContact }); + const ranking = rankIa360Sequences({ flow, signals }); + const summaryLines = buildIa360ContactSummaryLines(signals); + // G-COLD: si el contacto está fuera de la ventana de 24h, cada fila antepone + // la disponibilidad real del template frío (status en coexistence.message_templates). + // Fail-open con try/catch: si algo falla, el selector sale como hoy y se loggea. + let coldInfo = null; + try { + const win = await ia360OutsideWindow24h({ record, targetContact }); + if (win.known && win.outside) { + const statuses = await loadIa360ColdTemplateStatuses( + (flow.sequences || []).map(s => s.metaTemplateName).filter(Boolean) + ); + // null = falló el lookup (≠ inexistente): selector sin marcas, como hoy. + if (statuses === null) { + console.error('[ia360-cold] selector: lookup de statuses falló; selector sin marcas frías'); + } else { + coldInfo = { outsideWindow: true, statuses }; + } + } + } catch (e) { console.error('[ia360-cold] selector availability:', e.message); } + const bodyText = [ + `Alek, ${name} quedó como ${flow.personaContext}.`, + ...summaryLines, + ...(coldInfo ? ['Fuera de ventana de 24h: solo las secuencias marcadas «lista para frío» pueden salir hoy (como template de Meta).'] : []), + 'Elige una secuencia. Sigo en dry-run: no enviaré nada al contacto.', + ].join('\n'); + const suggestedReason = ranking.suggestedId ? ranking.reasonFor(ranking.suggestedId) : null; + // G-COLD: las filas se calculan una sola vez — se renderizan en la tarjeta y + // se persisten tal cual en ia360_selector_ranking.rows (auditoría 1:1). + const selectorRows = ranking.ordered.map(seq => { + const reason = ranking.suggestedId === seq.id ? ranking.reasonFor(seq.id) : null; + let description; + if (coldInfo) { + const avail = ia360ColdAvailability(seq.metaTemplateName ? coldInfo.statuses[seq.metaTemplateName] : undefined); + description = compactForWhatsApp(`${avail.label} · ${reason ? `Sugerida: ${reason}` : seq.goal}`, 72); + } else { + description = compactForWhatsApp(reason ? `Sugerida: ${reason}` : seq.goal, 72); + } + return { + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description, + }; + }); + // G-D: el ranking queda auditable en custom_fields (orden, sugerida, razón, + // resumen) — best-effort, no bloquea el envío de la tarjeta. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + customFields: { + ia360_selector_ranking: { + at: new Date().toISOString(), + persona: flowKey, + ranked: ranking.ranked, + suggested: ranking.suggestedId, + reason: suggestedReason, + order: ranking.ordered.map(seq => seq.id), + summary: summaryLines, + // G-COLD: filas tal como se renderizaron en la tarjeta. + rows: selectorRows, + ...(coldInfo ? { + cold: { + outside_window: true, + availability: Object.fromEntries( + (flow.sequences || []) + .filter(seq => seq.metaTemplateName) + .map(seq => { + const status = coldInfo.statuses[seq.metaTemplateName] || null; + return [seq.id, { template: seq.metaTemplateName, status, label: ia360ColdAvailability(status).label }]; + }) + ), + }, + } : {}), + }, + }, + }).catch(e => console.error('[ia360-rank] persist ranking:', e.message)); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: ranking.ranked + ? `IA360: secuencias ${name} — sugerida: ${ranking.suggestedId} (${suggestedReason})` + : `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { text: bodyText }, + footer: { + text: ranking.ranked + ? 'Sugerida primero por señales; aprobación antes de envío' + : 'Persona antes de secuencia; aprobación antes de envío', + }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + // G-COLD: mismas filas que quedaron persistidas en el ranking. + rows: selectorRows, + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + let readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + // G-COLD: aviso pre-aprobación. Si el contacto está fuera de la ventana de 24h, + // el owner debe saber ANTES de aprobar si el opener saldrá como template de + // Meta (mismo copy, con botones) o si no puede salir nada todavía. + // Fail-open: si la verificación falla, el readout sale como hoy. + let coldBlocked = false; + let coldNotice = null; + try { + const win = await ia360OutsideWindow24h({ record, targetContact }); + if (win.known && win.outside) { + const tplName = sequence.metaTemplateName || null; + if (!tplName) { + // Defensivo: hoy las 24 secuencias tienen template mapeado, pero si una + // nueva llega sin él, el aviso no debe imprimir «null». + coldBlocked = true; + readout += `\n\nAVISO: ${name} está fuera de la ventana de 24h y esta secuencia no tiene template frío mapeado. Si apruebas ahora NO puede salir nada.`; + coldNotice = 'Fuera de ventana de 24h y sin template frío mapeado: hoy no puede salir nada.'; + } else { + const statuses = await loadIa360ColdTemplateStatuses([tplName]); + if (statuses === null) { + // null = falló el lookup (≠ inexistente): sin aviso ni coldBlocked, + // comportamiento previo; el cinturón del approve-send re-verifica. + console.error('[ia360-cold] readout: lookup de statuses falló; readout sin aviso frío'); + } else { + const avail = ia360ColdAvailability(statuses[tplName]); + if (avail.sendable) { + readout += `\n\nFuera de ventana de 24h: si apruebas, el opener saldrá como template aprobado de Meta «${tplName}» (mismo copy, con sus botones), no como texto libre.`; + coldNotice = `Fuera de ventana de 24h: el opener saldrá como template «${tplName}».`; + } else { + coldBlocked = true; + readout += `\n\nAVISO: ${name} está fuera de la ventana de 24h y el template «${tplName}» de esta secuencia aún no está aprobado por Meta (${avail.label}). Si apruebas ahora NO puede salir nada. Opciones: espera la aprobación de Meta, elige una secuencia marcada «lista para frío» o toma el contacto manual.`; + coldNotice = `Fuera de ventana de 24h y sin template aprobado (${avail.label}): hoy no puede salir nada.`; + } + } + } + } + } catch (e) { console.error('[ia360-cold] readout availability:', e.message); } + // G-COLD: best-effort, solo auditoría/QA — el cinturón del approve-send + // re-consulta el status en vivo; nadie lee este campo en runtime. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + customFields: { ia360_approve_card_cold_blocked: coldBlocked }, + }).catch(e => console.error('[ia360-cold] persist cold_blocked:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence, coldBlocked, coldNotice }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence, coldBlocked = false, coldNotice = null }) { + // G-COLD: con coldNotice el owner ve en la tarjeta cómo saldría el opener (o + // por qué no puede salir); con coldBlocked se RETIRA la fila "Aprobar y + // enviar" — el owner jamás debe poder aprobar algo imposible. + let bodyText = `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`; + if (coldNotice) bodyText += `\n${coldNotice}`; + if (bodyText.length > 1024) bodyText = compactForWhatsApp(bodyText, 1024); + const rows = [ + ...(coldBlocked ? [] : [{ id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }]), + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ]; + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { text: bodyText }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows, + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // G-C: dedupe de doble tap. Si esta misma secuencia ya fue aprobada y su envío + // ya salió SIN fallar, un segundo tap de la tarjeta NO debe generar otro egress. + // Un envío fallido NO bloquea: el owner puede reintentar con la misma tarjeta. + if (pf.approval?.status === 'approved' && pf.send?.sent_at && String(pf.send.send_status || '').toLowerCase() !== 'failed' && !pf.send.error) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_dup', + body: `Ese opener ("${sequence.label}") ya se había enviado a ${name} (${pf.send.sent_at}). Detecté un doble tap y no envié nada nuevo.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // GUARDIA cliente_expansion (D7): la secuencia presupone un proyecto andando. + // Solo dispara si el contacto tiene un deal vivo (status='open') en P2 (IA360 + // WhatsApp Revenue Pipeline) o P7 (Champions). Sin deal vivo → bloquear con aviso. + // G-C: con try/catch — si la consulta falla, el owner se entera (nunca mudo) y + // NO se envía nada (fail-closed). + if (sequence.requiresLiveDeal) { + let liveRows; + try { + ({ rows: liveRows } = await pool.query( + `SELECT 1 + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') + AND d.contact_wa_number = $1 + AND d.contact_number = $2 + AND d.status = 'open' + LIMIT 1`, + [record.wa_number, targetContact] + )); + } catch (liveErr) { + console.error('[ia360-approve] live deal check failed:', liveErr.message); + return deny('live_deal_check_failed', `No pude verificar si ${name} tiene un proyecto activo (error de base de datos). Por seguridad no envié nada; reintenta en un momento.`); + } + if (!liveRows.length) { + return deny('no_live_deal', `${name} no tiene un proyecto activo (deal vivo en P2/P7). La secuencia ${sequence.id} solo aplica a clientes con proyecto en curso; elige otra secuencia. No envié nada.`); + } + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template). + // G-COLD: las 24 secuencias persona-first ya tienen metaTemplateName mapeado; + // el deny outside_window_no_template queda solo como red de seguridad. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + // Openers v2: dentro de ventana el opener sale como interactive (botones/lista) + // con el copy aprobado en el readout; secuencias sin openerOptions siguen en texto. + const openerInteractive = buildIa360OpenerInteractive({ sequence, bodyText: pf.sequence_candidate.draft }); + let sent; + let handlerFor; + if (openerInteractive) { + sent = await enqueueIa360Interactive({ + record: targetRecord, + label: openerLabel, + messageBody: `IA360 opener: ${sequence.label}`, + interactive: openerInteractive, + dedupSuffix: `:opener:${targetContact}`, + }); + handlerFor = `${record.message_id}:opener:${targetContact}`; + } else { + sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + handlerFor = `${record.message_id}:direct:${targetContact}`; + } + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(handlerFor); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + // G-COLD: cinturón contra tarjetas viejas o races. La tarjeta sin la fila + // "Aprobar y enviar" protege la UX, pero un tap sobre una tarjeta emitida + // antes (o un cambio de status en Meta entre tarjeta y tap) llegaría hasta + // aquí: se consulta el status REAL del template antes de encolar nada. + const coldStatuses = await loadIa360ColdTemplateStatuses([sequence.metaTemplateName]); + if (coldStatuses === null) { + // null = falló la consulta (≠ template inexistente): fail-closed en el + // envío, pero con diagnóstico honesto para el owner. + return deny('cold_template_status_check_failed', `No pude verificar el status del template «${sequence.metaTemplateName}» en la base (error de consulta). Por seguridad no envié nada; reintenta en un momento.`); + } + const coldStatus = coldStatuses[sequence.metaTemplateName] || null; + if (String(coldStatus || '').toUpperCase() !== 'APPROVED') { + return deny('outside_window_template_not_approved', `${name} está fuera de la ventana de 24h y el template «${sequence.metaTemplateName}» de la secuencia ${sequence.id} aún no está aprobado por Meta (${coldStatus || 'inexistente'}). No envié nada.`); + } + // G-COLD: referido_contexto en frío necesita el {{2}} (quien_intro), igual + // que el draft caliente lo calcula buildIa360PersonaPayload. Sin el dato, + // el template saldría con un hueco — mejor avisar con honestidad. + // Sanitizado vía compactForWhatsApp (colapsa espacios/saltos y topa a 60): + // Meta rechaza parámetros con saltos de línea o 4+ espacios consecutivos. + let templateVars = null; + if (sequence.id === 'referido_contexto') { + const quienIntro = compactForWhatsApp(contact.custom_fields?.quien_intro || '', 60); + if (!quienIntro) { + return deny('cold_send_missing_quien_intro', `El template de ${sequence.id} necesita saber quién hizo la introducción y ${name} no tiene quien_intro registrado. Captura ese dato (o elige otra secuencia) y vuelve a intentar. No envié nada.`); + } + templateVars = { '2': quienIntro }; + } + // allowTextFallback:false — en frío un fallback a texto libre sería + // rechazado por Meta y reportaría éxito falso (ver enqueueIa360Template). + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName, vars: templateVars, allowTextFallback: false }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + // G-C: un opener nuevo abre un ciclo nuevo — la respuesta del ciclo anterior + // no debe activar el dedupe del router seq_* (el contacto debe poder volver + // a elegir la misma opción y recibir su paso 2). + ia360_seq_last_response: null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── Expediente del owner: "qué sabes de " ────────────────── +// Comando read-only del owner: arma un expediente con los facts y eventos de +// coexistence.ia360_memory_* para un contacto, resuelto por número o por +// nombre (tolerante a acentos y a typos simples tipo Emmanuel/Emanuel). +// Egress SOLO vía sendIa360DirectText; nunca escribe memoria y SIEMPRE +// responde algo (sin expediente / candidatos / error), nunca queda mudo. +const IA360_BOT_WA_NUMBER = '5213321594582'; // número del bot: jamás es contacto + +function parseIa360OwnerMemoryQuery(body) { + const text = String(body || '').trim(); + const m = text.match(/^¿?\s*(?:qu[eé]|qui[eé]n)\s+sabes\s+(?:de\s+la|de\s+el|del|de|sobre)\s+(.+?)\s*\?*$/i); + if (!m) return null; + const q = m[1].trim(); + return q || null; +} + +// Normaliza para comparar nombres: minúsculas, sin acentos y con letras +// repetidas colapsadas ("Emmanuel" y "Emanuel" → "emanuel"). +function ia360NormalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +async function resolveIa360MemoryTarget(query) { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + // Número directo: 10 dígitos MX → prefijo 521 (formato ForgeChat). + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const { rows } = await pool.query( + `SELECT DISTINCT contact_number, contact_name FROM ( + SELECT contact_number, contact_name + FROM coexistence.ia360_memory_events + WHERE contact_name IS NOT NULL AND contact_number IS NOT NULL + UNION ALL + SELECT contact_number, COALESCE(name, profile_name) AS contact_name + FROM coexistence.contacts + WHERE COALESCE(name, profile_name) IS NOT NULL AND contact_number IS NOT NULL + ) t + WHERE contact_number <> $1`, + [IA360_BOT_WA_NUMBER] + ); + const needle = ia360NormalizeNameForMatch(query); + if (!needle) return { kind: 'none', candidates: [] }; + const byNumber = new Map(); + for (const r of rows) { + if (!ia360NormalizeNameForMatch(r.contact_name).includes(needle)) continue; + if (!byNumber.has(r.contact_number)) byNumber.set(r.contact_number, r); + } + const candidates = [...byNumber.values()]; + if (!candidates.length) return { kind: 'none', candidates: [] }; + if (candidates.length > 1) return { kind: 'ambiguous', candidates }; + return { kind: 'name', candidates }; +} + +async function buildIa360ContactDossier(contactNumber) { + const num = normalizePhone(contactNumber); + const { rows: factRows } = await pool.query( + `SELECT project_name, persona, role, account_name, preference, objection, + recurring_pain, affected_process, missing_metric + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + ORDER BY last_seen_at DESC, id DESC`, + [num] + ); + const { rows: eventRows } = await pool.query( + `SELECT contact_name, area, signal_type, summary + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC, id DESC + LIMIT 12`, + [num] + ); + if (!factRows.length && !eventRows.length) return null; + + const name = eventRows.find(e => e.contact_name)?.contact_name || null; + const header = [`Expediente IA360: ${name || 'contacto'} (${num})`]; + const meta = []; + const accountRow = factRows.find(f => f.account_name); + const projectRow = factRows.find(f => f.project_name); + const personaRow = factRows.find(f => f.persona); + if (accountRow) meta.push(`Cuenta: ${accountRow.account_name}`); + if (projectRow) meta.push(`Proyecto: ${projectRow.project_name}`); + if (personaRow) meta.push(`Persona: ${personaRow.persona}`); + if (meta.length) header.push(meta.join(' · ')); + + // Los facts viven duplicados por el doble keying de contact_wa_number + // (monolito vs lookup v2): dedupe por contenido, no por fila. + const factLines = []; + const seenFacts = new Set(); + for (const f of factRows) { + for (const field of ['preference', 'objection', 'recurring_pain', 'affected_process', 'missing_metric']) { + const val = String(f[field] || '').trim(); + if (!val) continue; + const key = `${field}:${val}`; + if (seenFacts.has(key)) continue; + seenFacts.add(key); + factLines.push(`- ${val.length > 300 ? `${val.slice(0, 297)}...` : val}`); + } + } + const eventLines = []; + const seenEvents = new Set(); + for (const e of eventRows) { + const val = String(e.summary || '').trim(); + if (!val) continue; + const key = `${e.area}|${e.signal_type}|${val}`; + if (seenEvents.has(key)) continue; + seenEvents.add(key); + eventLines.push(`- [${e.area}/${e.signal_type}] ${val.length > 220 ? `${val.slice(0, 217)}...` : val}`); + } + + const lines = [...header, '']; + if (factLines.length) lines.push(`Facts (${factLines.length}):`, ...factLines, ''); + if (eventLines.length) lines.push(`Eventos recientes (${eventLines.length}):`, ...eventLines); + let body = lines.join('\n').trim(); + // Límite duro de WhatsApp: 4096 chars por texto. + if (body.length > 3900) body = `${body.slice(0, 3880)}\n... (recortado)`; + return body; +} + +async function handleIa360OwnerMemoryQuery({ record, query }) { + let body; + try { + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + body = `Sin expediente: no encontré facts ni eventos para "${query}". Revisa el nombre o mándame el número completo.`; + } else if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + body = `Encontré varios contactos que coinciden con "${query}". ¿De cuál quieres el expediente?\n${list}\n\nMándame "qué sabes de " para verlo.`; + } else { + const dossier = await buildIa360ContactDossier(target.candidates[0].contact_number); + body = dossier + || `Sin expediente: el contacto ${target.candidates[0].contact_number} no tiene facts ni eventos guardados todavía.`; + } + } catch (err) { + console.error('[ia360-expediente] dossier error:', err.message); + body = `No pude leer el expediente de "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); +} + +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu pregunta. Para responderte bien y no darte una respuesta incompleta, voy a revisar el contexto con Alek y te confirmo por aquí en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) { + responded = true; + return; + } + // Cliente activo/beta con pregunta sustantiva pero sin señales suficientes para + // contestar desde memoria: nunca inventar ni dejar en silencio. Enviar holding + // claro al contacto y alertar a Alek para investigar el proyecto/fuente canónica. + await handleIa360BotFailure({ + record, + reason: 'cliente activo/beta sin contexto suficiente en memoria para responder sin alucinar', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] cliente-activo fallback error:', e.message)); + responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // ── G-C: ruteo de respuestas a openers v2 (ids seq_* y alias de template) ── + // Va DESPUÉS de Revenue OS (que resuelve su propio "Sí, cuéntame" gateado por + // estado) y ANTES del embudo 100M. Un id seq_* del catálogo NUNCA cae al + // fallback global. + if (replyId && replyId.startsWith('seq_')) { + if (await handleIa360SequenceReply({ record, replyId })) return; + } else if (replyId || answer) { + const aliasKey = String(replyId || answer || '').trim().toLowerCase(); + if (IA360_SEQ_ALIAS_NEGATIVE.has(aliasKey) || IA360_SEQ_ALIAS_HANDOFF.has(aliasKey) || IA360_SEQ_ALIAS_AFFIRMATIVE.has(aliasKey)) { + try { + const aliasContact = await loadIa360ContactContext(record).catch(() => null); + const aliased = resolveIa360TemplateButtonAlias({ replyId: aliasKey, contact: aliasContact }); + if (aliased && await handleIa360SequenceReply({ record, replyId: aliased, contact: aliasContact })) return; + } catch (aliasErr) { + console.error('[ia360-seq] alias error:', aliasErr.message); + } + } + } + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + // G-C anti-loop: "No prioritario" ya NO ofrece "Aplicarlo" (reabría la rama + // comercial); las salidas son nutrición ("Más adelante") o baja. + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + // ── G-C: anti-loop del router 100M ────────────────────────────────────── + // 'baja' (optout) SIEMPRE pasa: la salida del contacto no se bloquea nunca. + if (flow100m.tag !== 'no-contactar') { + try { + const guard = await ia360HundredMAdvancedGuard(record); + // Guard de estado/versión: la conversación ya avanzó a agenda/reunión/ + // handoff humano → un botón de un mensaje viejo NO reabre la rama. + if (guard.advanced) { + await enqueueIa360Text({ record, label: 'ia360_100m_continuity', body: guard.body }); + return; + } + // Nodo loop-prone repetido → versión condensada con salidas terminales, + // no el bloque completo otra vez. Si la lectura del contacto falló, NO se + // escribe el mapa de visitas (se pisaría con un objeto vacío). + const visited = guard.visited || {}; + if (guard.visitedOk && IA360_100M_LOOP_PRONE.has(flow100m.tag)) { + if (visited[flow100m.tag]) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: (Number(visited[flow100m.tag]) || 0) + 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + await enqueueIa360Interactive({ + record, + label: 'ia360_100m_condensed', + messageBody: `IA360 100M: ${flow100m.title} (resumen)`, + interactive: { + type: 'button', + body: { text: `Eso ya lo vimos: ${flow100m.title}. Para no darte vueltas con lo mismo, mejor dime cómo cerramos: ¿agendamos una llamada corta con Alek o lo dejamos para más adelante?` }, + footer: { text: 'IA360 · sin vueltas' }, + action: { + buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada con Alek' } }, + { type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }, + ], + }, + }, + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + } + } catch (guardErr) { + console.error('[ia360-100m] guard error:', guardErr.message); + } + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + }, + }); + return; + } + + // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── + // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo o + // malformado). Los ids seq_* del catálogo y los quick replies de template con + // estado persona-first ya se rutean arriba (handleIa360SequenceReply + alias); + // aquí solo cae lo verdaderamente desconocido. El contacto siempre recibe + // acuse y el owner se entera. try/catch terminal: nunca tumba el webhook. + try { + const fallbackId = replyId || answer || '(sin id)'; + console.warn('[ia360-fallback] unhandled interactive reply contact=%s id=%s body=%s', record.contact_number || '-', fallbackId, String(record.message_body || '').slice(0, 80)); + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_ack', + body: `Recibí tu respuesta "${String(record.message_body || fallbackId).slice(0, 60)}", pero aún no tengo una acción conectada para ese botón (${fallbackId}). No hice ningún cambio.`, + }); + return; + } + await enqueueIa360Text({ + record, + label: 'ia360_interactive_fallback', + body: 'Recibí tu respuesta y la estoy ubicando para darte una respuesta útil. Si es urgente, Alek también puede escribirte directo.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_notice', + body: `Alek, ${record.contact_name || record.contact_number} (${record.contact_number}) respondió "${String(record.message_body || fallbackId).slice(0, 60)}" (id: ${fallbackId}) y no tengo un manejador para esa opción. Le acusé recibo; revisa si quieres tomarlo tú.`, + targetContact: record.contact_number, + ownerBudget: true, + }); + } catch (fbErr) { + console.error('[ia360-fallback] interactive fallback error:', fbErr.message); + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── EXPEDIENTE: comando del owner "qué sabes de " ── + // Mismo patrón que "idea:": va ANTES del canary Brain v2 (el owner + // está en la allowlist y el canary haría continue). Read-only sobre + // ia360_memory_facts/events; responde SIEMPRE (nunca queda mudo). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const memQuery = parseIa360OwnerMemoryQuery(record.message_body); + if (memQuery) { + await handleIa360OwnerMemoryQuery({ record, query: memQuery }) + .catch(e => console.error('[ia360-expediente] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + // G-WIN cartera: el bot no lee imágenes. Si el contacto está a media + // captura de tabla (esperando_tabla) y manda foto/archivo, pedimos la + // versión en texto y no seguimos el embudo para este record. + if (await handleCarteraMediaInbound(record)) { + continue; + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // G-WIN "Mapa de cartera" (P7 Champions) — mismo contrato que Revenue OS: + // gateado por persona+tema+estado; si actúa, CORTA el embudo para que el + // agente genérico no responda encima (guardrail: sin pitch, sin agenda). + const carteraHandled = revHandled + ? false + : await handleCarteraFreeText(record).catch(e => { console.error('[cartera] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled && !carteraHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +// G-WIN cartera — vista previa del flujo completo al WhatsApp del OWNER (nunca +// a un contacto): los mensajes de los 3 pasos con datos de ejemplo, en UN solo +// texto, para aprobación de copy. Egress único: sendIa360DirectText → +// messageSender. Auth = X-IA360-Directive-Secret (patrón de los endpoints +// internos). Idempotencia: el caller decide cuándo (una sola vez por sesión). +router.post('/internal/ia360-cartera/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const ejemploRows = [ + { cuenta: 'Transportes del Bajío', saldo_portal: '$1,250,000.00', saldo_correcto: '$980,000.00', fecha_corte: '31/05/2026', responsable: 'Laura' }, + { cuenta: 'Logística Occidente', saldo_portal: '$430,500.00', saldo_correcto: '$512,300.00', fecha_corte: '31/05/2026', responsable: 'Marco' }, + ]; + const mapaEjemplo = buildCarteraMapa(ejemploRows); + const preview = [ + 'IA360 · PREVIEW flujo "Mapa de cartera" (P7 Champions) — para tu aprobación. Nada de esto se envió a Andrés.', + '', + '── PASO 1 · El contacto reporta saldos que no cuadran. El bot responde: ──', + '', + IA360_CARTERA_COPY.paso1, + '', + '── Si manda foto o archivo en lugar de texto: ──', + '', + IA360_CARTERA_COPY.pideTexto, + '', + '── PASO 2 · El contacto pega la tabla. El bot responde (datos de ejemplo): ──', + '', + mapaEjemplo.texto, + '', + '── PASO 3 · Tú recibes este readout y el deal avanza a "Quick win entregado": ──', + '', + 'IA360 · Quick win cartera — (contacto) (521***XX)', + '', + 'El contacto entregó su tabla de cartera (2 cuentas) y le devolví el mapa estructurado.', + `- Cuentas con descuadre: ${mapaEjemplo.cuentasConDescuadre} · Diferencia acumulada: ${carteraFormatoMonto(mapaEjemplo.diferenciaTotal)}`, + '- Deal: «IA360 · (contacto) · Quick win cartera» → Quick win entregado (P7 Champions).', + '- Mapa encolado a ia360_docs_sync (destino AlekContenido).', + ].join('\n'); + const record = { + wa_number: normalizePhone(req.body?.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'), + contact_number: IA360_OWNER_NUMBER, + message_id: `cartera_preview:${Date.now()}`, + message_type: 'cartera_preview', + message_body: '', + }; + const sent = await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'ia360_cartera_preview_owner', + body: preview, + }); + return res.status(sent ? 200 : 502).json({ ok: !!sent, schema: 'ia360_cartera_preview.v1', chars: preview.length }); + } catch (err) { + console.error('[cartera] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// OPENERS V2 — vista previa de un opener al WhatsApp del OWNER (nunca a un +// contacto). Renderiza el draft v2 (primer nombre + quien_intro opcional) y el +// interactive (botones/lista) tal como lo vería el contacto. Único egress: +// sendOwnerInteractive / sendIa360DirectText -> messageSender. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). +router.post('/internal/ia360-openers/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const sequenceId = String(b.sequence_id || '').trim().toLowerCase(); + const found = findIa360SequenceFlow(sequenceId); + if (!found) return res.status(422).json({ ok: false, error: 'unknown_sequence', sequence_id: sequenceId }); + const { sequence } = found; + const sampleName = ia360FirstNameFrom(String(b.name || 'Alek').trim() || 'Alek'); + const quienIntro = String(b.quien_intro || '').trim() || null; + const bodyText = typeof sequence.draft === 'function' ? sequence.draft({ name: sampleName, quienIntro }) : String(sequence.draft || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const synthetic = { + wa_number: waNumber, + contact_number: IA360_OWNER_NUMBER, + message_id: `opener-preview-${sequenceId}-${Date.now()}`, + message_type: 'text', + direction: 'incoming', + }; + const interactive = buildIa360OpenerInteractive({ sequence, bodyText }); + let sent; + if (interactive) { + // ownerBudget=false: la preview es una petición explícita del owner; no debe + // caer en el presupuesto anti-spam de notificaciones. + sent = await sendOwnerInteractive({ + record: synthetic, + label: `ia360_opener_preview_${sequenceId}`, + messageBody: `IA360 preview opener ${sequenceId}`, + interactive, + }); + } else { + sent = await sendIa360DirectText({ + record: synthetic, + toNumber: IA360_OWNER_NUMBER, + label: `ia360_opener_preview_${sequenceId}`, + body: bodyText, + }); + } + return res.status(sent ? 200 : 502).json({ + ok: Boolean(sent), + schema: 'ia360_opener_preview.v1', + sequence_id: sequenceId, + kind: interactive ? (interactive.type === 'list' ? 'list' : 'buttons') : 'text', + body_preview: bodyText, + }); + } catch (err) { + console.error('[ia360-openers] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-nodouble-20260602T234257Z b/backend/src/routes/webhook.js.bak-pre-nodouble-20260602T234257Z new file mode 100644 index 0000000..4dcc4ee --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-nodouble-20260602T234257Z @@ -0,0 +1,1729 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|cambiar|otro d[ií]a|otra hora|otro horario|posponer|recorrer|adelantar|cancel/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if (agent.action === 'offer_slots' && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId] || answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-openersv2-20260610T171316Z b/backend/src/routes/webhook.js.bak-pre-openersv2-20260610T171316Z new file mode 100644 index 0000000..2b11c11 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-openersv2-20260610T171316Z @@ -0,0 +1,6551 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { + text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, + }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows: [ + { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ], + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); + // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + const sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(`${record.message_id}:direct:${targetContact}`); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── Expediente del owner: "qué sabes de " ────────────────── +// Comando read-only del owner: arma un expediente con los facts y eventos de +// coexistence.ia360_memory_* para un contacto, resuelto por número o por +// nombre (tolerante a acentos y a typos simples tipo Emmanuel/Emanuel). +// Egress SOLO vía sendIa360DirectText; nunca escribe memoria y SIEMPRE +// responde algo (sin expediente / candidatos / error), nunca queda mudo. +const IA360_BOT_WA_NUMBER = '5213321594582'; // número del bot: jamás es contacto + +function parseIa360OwnerMemoryQuery(body) { + const text = String(body || '').trim(); + const m = text.match(/^¿?\s*(?:qu[eé]|qui[eé]n)\s+sabes\s+(?:de\s+la|de\s+el|del|de|sobre)\s+(.+?)\s*\?*$/i); + if (!m) return null; + const q = m[1].trim(); + return q || null; +} + +// Normaliza para comparar nombres: minúsculas, sin acentos y con letras +// repetidas colapsadas ("Emmanuel" y "Emanuel" → "emanuel"). +function ia360NormalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +async function resolveIa360MemoryTarget(query) { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + // Número directo: 10 dígitos MX → prefijo 521 (formato ForgeChat). + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const { rows } = await pool.query( + `SELECT DISTINCT contact_number, contact_name FROM ( + SELECT contact_number, contact_name + FROM coexistence.ia360_memory_events + WHERE contact_name IS NOT NULL AND contact_number IS NOT NULL + UNION ALL + SELECT contact_number, COALESCE(name, profile_name) AS contact_name + FROM coexistence.contacts + WHERE COALESCE(name, profile_name) IS NOT NULL AND contact_number IS NOT NULL + ) t + WHERE contact_number <> $1`, + [IA360_BOT_WA_NUMBER] + ); + const needle = ia360NormalizeNameForMatch(query); + if (!needle) return { kind: 'none', candidates: [] }; + const byNumber = new Map(); + for (const r of rows) { + if (!ia360NormalizeNameForMatch(r.contact_name).includes(needle)) continue; + if (!byNumber.has(r.contact_number)) byNumber.set(r.contact_number, r); + } + const candidates = [...byNumber.values()]; + if (!candidates.length) return { kind: 'none', candidates: [] }; + if (candidates.length > 1) return { kind: 'ambiguous', candidates }; + return { kind: 'name', candidates }; +} + +async function buildIa360ContactDossier(contactNumber) { + const num = normalizePhone(contactNumber); + const { rows: factRows } = await pool.query( + `SELECT project_name, persona, role, account_name, preference, objection, + recurring_pain, affected_process, missing_metric + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + ORDER BY last_seen_at DESC, id DESC`, + [num] + ); + const { rows: eventRows } = await pool.query( + `SELECT contact_name, area, signal_type, summary + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC, id DESC + LIMIT 12`, + [num] + ); + if (!factRows.length && !eventRows.length) return null; + + const name = eventRows.find(e => e.contact_name)?.contact_name || null; + const header = [`Expediente IA360: ${name || 'contacto'} (${num})`]; + const meta = []; + const accountRow = factRows.find(f => f.account_name); + const projectRow = factRows.find(f => f.project_name); + const personaRow = factRows.find(f => f.persona); + if (accountRow) meta.push(`Cuenta: ${accountRow.account_name}`); + if (projectRow) meta.push(`Proyecto: ${projectRow.project_name}`); + if (personaRow) meta.push(`Persona: ${personaRow.persona}`); + if (meta.length) header.push(meta.join(' · ')); + + // Los facts viven duplicados por el doble keying de contact_wa_number + // (monolito vs lookup v2): dedupe por contenido, no por fila. + const factLines = []; + const seenFacts = new Set(); + for (const f of factRows) { + for (const field of ['preference', 'objection', 'recurring_pain', 'affected_process', 'missing_metric']) { + const val = String(f[field] || '').trim(); + if (!val) continue; + const key = `${field}:${val}`; + if (seenFacts.has(key)) continue; + seenFacts.add(key); + factLines.push(`- ${val.length > 300 ? `${val.slice(0, 297)}...` : val}`); + } + } + const eventLines = []; + const seenEvents = new Set(); + for (const e of eventRows) { + const val = String(e.summary || '').trim(); + if (!val) continue; + const key = `${e.area}|${e.signal_type}|${val}`; + if (seenEvents.has(key)) continue; + seenEvents.add(key); + eventLines.push(`- [${e.area}/${e.signal_type}] ${val.length > 220 ? `${val.slice(0, 217)}...` : val}`); + } + + const lines = [...header, '']; + if (factLines.length) lines.push(`Facts (${factLines.length}):`, ...factLines, ''); + if (eventLines.length) lines.push(`Eventos recientes (${eventLines.length}):`, ...eventLines); + let body = lines.join('\n').trim(); + // Límite duro de WhatsApp: 4096 chars por texto. + if (body.length > 3900) body = `${body.slice(0, 3880)}\n... (recortado)`; + return body; +} + +async function handleIa360OwnerMemoryQuery({ record, query }) { + let body; + try { + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + body = `Sin expediente: no encontré facts ni eventos para "${query}". Revisa el nombre o mándame el número completo.`; + } else if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + body = `Encontré varios contactos que coinciden con "${query}". ¿De cuál quieres el expediente?\n${list}\n\nMándame "qué sabes de " para verlo.`; + } else { + const dossier = await buildIa360ContactDossier(target.candidates[0].contact_number); + body = dossier + || `Sin expediente: el contacto ${target.candidates[0].contact_number} no tiene facts ni eventos guardados todavía.`; + } + } catch (err) { + console.error('[ia360-expediente] dossier error:', err.message); + body = `No pude leer el expediente de "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); +} + +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── EXPEDIENTE: comando del owner "qué sabes de " ── + // Mismo patrón que "idea:": va ANTES del canary Brain v2 (el owner + // está en la allowlist y el canary haría continue). Read-only sobre + // ia360_memory_facts/events; responde SIEMPRE (nunca queda mudo). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const memQuery = parseIa360OwnerMemoryQuery(record.message_body); + if (memQuery) { + await handleIa360OwnerMemoryQuery({ record, query: memQuery }) + .catch(e => console.error('[ia360-expediente] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-regex-20260602T234451Z b/backend/src/routes/webhook.js.bak-pre-regex-20260602T234451Z new file mode 100644 index 0000000..198e0f0 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-regex-20260602T234451Z @@ -0,0 +1,1753 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|cambiar|otro d[ií]a|otra hora|otro horario|posponer|recorrer|adelantar|cancel/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if (agent.action === 'offer_slots' && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId] || answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-reschedule-20260602T230345Z b/backend/src/routes/webhook.js.bak-pre-reschedule-20260602T230345Z new file mode 100644 index 0000000..cd9237c --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-reschedule-20260602T230345Z @@ -0,0 +1,1698 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + // Terminal = won/lost or an already-scheduled meeting → do not auto-answer. + const terminalStages = ['Reunión agendada', 'Ganado', 'Perdido / no fit']; + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || terminalStages.includes(deal.stage_name)) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) return; + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if (agent.action === 'offer_slots' && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId] || answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, reunión confirmada.\n\nHora: ${confirmedTime} (CDMX)\nZoom: ${booking.zoomJoinUrl}\n\nTambién quedó en Google Calendar de Alek.`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-revenueos-20260609T002702 b/backend/src/routes/webhook.js.bak-pre-revenueos-20260609T002702 new file mode 100644 index 0000000..ecee1ab --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-revenueos-20260609T002702 @@ -0,0 +1,5465 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-revert-fix2-20260603T011907Z b/backend/src/routes/webhook.js.bak-pre-revert-fix2-20260603T011907Z new file mode 100644 index 0000000..b32e78b --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-revert-fix2-20260603T011907Z @@ -0,0 +1,1767 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" only passes for reschedule intent (avoids spurious re-fire on every "gracias"). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +async function handleIa360FreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + await enqueueIa360Text({ + record, + label: isCancel ? 'ia360_ai_cancel_request' : 'ia360_ai_reschedule_request', + body: isCancel + ? 'Entendido, le aviso a Alek para cancelar la reunión. Te confirma en breve por aquí.' + : 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + emitIa360N8nHandoff({ + record, + eventType: isCancel ? 'meeting_cancel_requested' : 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió ${isCancel ? 'CANCELAR' : 'REPROGRAMAR'} su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: ${isCancel ? 'cancelar el evento Calendar/Zoom existente' : 'mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo)'} y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule/cancel handoff:', e.message)); + return; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + return; + } + + // OFFER SLOTS → query REAL availability for the agent's date, send list. + if ((agent.action === 'offer_slots' || agent.action === 'book') && agent.date) { + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${agent.date}; se consulta Calendar real`, + }); + // Single outbound only (resolveIa360Outbound dedups per inbound message_id): + // fold the agent reply into the slot-list body below instead of a separate text. + // Availability node accepts an explicit ISO `date` override (NOT `day`). + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let availability = null; + if (url) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', date: agent.date, workStartHour: 10, workEndHour: 18, slotMinutes: 60 }), + }); + availability = r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); } + } + const slots = (availability && availability.slots) || []; + if (slots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek para ese día y no hay espacios de 1 hora libres. ¿Quieres que te ofrezca otro día?' }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + `Estos espacios de 1 hora estan libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: slots.slice(0, 10).map((slot) => ({ id: slot.id, title: slot.title, description: slot.description })) }], + }, + }, + }); + return; + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // G-G: hot lead que alcanza "Requiere Alek" debe surgir en EspoCRM aunque + // nunca se autoagende (si no, el lead mas caliente es invisible para Alek). + // Idempotente: el handoff n8n hace upsert de Contact/Opportunity/Task por nombre. + if (flow100m.stage === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'hot_lead_requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Hot lead IA360 (${flow100m.title}): "${record.message_body}". Crear/actualizar Contact + Opportunity (Qualification) + Task ALTA; preparar contexto para que Alek contacte de inmediato.`, + }).catch(e => console.error('[ia360-n8n] hot-lead handoff:', e.message)); + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + const selectedSlot = parseIa360SlotId(replyId); + if (selectedSlot) { + const booking = await bookIa360Slot({ record, ...selectedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-pre-validator-20260609T194227Z b/backend/src/routes/webhook.js.bak-pre-validator-20260609T194227Z new file mode 100644 index 0000000..474408e --- /dev/null +++ b/backend/src/routes/webhook.js.bak-pre-validator-20260609T194227Z @@ -0,0 +1,5918 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-qa-persona-hint-20260605T174515 b/backend/src/routes/webhook.js.bak-qa-persona-hint-20260605T174515 new file mode 100644 index 0000000..10c1afb --- /dev/null +++ b/backend/src/routes/webhook.js.bak-qa-persona-hint-20260605T174515 @@ -0,0 +1,4627 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: record.message_body || '', + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + const provided = req.get('X-IA360-Directive-Secret') || ''; + if (!IA360_DIRECTIVE_SECRET || !safeEqual(provided, IA360_DIRECTIVE_SECRET)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-quickwins-1780535250 b/backend/src/routes/webhook.js.bak-quickwins-1780535250 new file mode 100644 index 0000000..1b912d8 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-quickwins-1780535250 @@ -0,0 +1,2936 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. Le quedan ${remaining.length} reunión(es).` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-w4-1780537976 b/backend/src/routes/webhook.js.bak-w4-1780537976 new file mode 100644 index 0000000..0e22539 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-w4-1780537976 @@ -0,0 +1,2940 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record) { + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, record.message_id] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'preferences: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `preferences: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak-w4loopfix-1780539588 b/backend/src/routes/webhook.js.bak-w4loopfix-1780539588 new file mode 100644 index 0000000..0237463 --- /dev/null +++ b/backend/src/routes/webhook.js.bak-w4loopfix-1780539588 @@ -0,0 +1,3071 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) return null; + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-answer. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') return null; + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +async function callIa360Agent({ record, stageName }) { + const url = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (!url) return null; + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + text: record.message_body || '', + stage: stageName, + history, + }), + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + return await res.json(); + } catch (err) { + console.error('[ia360-agent] error:', err.message); + return null; + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +const IA360_OWNER_NUMBER = '5213322638033'; + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label }) { + try { + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Con tu perfil (${tamano_empresa}, ${personas_afectadas}) te propongo arrancar por ${tipo_solucion} en nivel ${tier}. ¿Lo aterrizamos en una llamada?` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Ver mapa' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto llego preparado a la llamada (no demo de cajón). ¿Confirmamos horario?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_schedule', title: 'Elegir horario' } }, + ] }, + }, + }); + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_preferences', + messageBody: 'IA360 Flow: preferencias', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Listo, te mando solo lo útil y sin saturarte.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + ] }, + }, + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + try { + const [ownerAction, ownerArg] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Anotado, lo tomas tú.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'reslots') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la llamada' }, + body: { text: '¿Quieres que Alek llegue con tu contexto a la mano? Mándamelo en 30 segundos y prepara la sesión a tu caso.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/webhook.js.bak.gap15.20260608_222354 b/backend/src/routes/webhook.js.bak.gap15.20260608_222354 new file mode 100644 index 0000000..d1c6055 --- /dev/null +++ b/backend/src/routes/webhook.js.bak.gap15.20260608_222354 @@ -0,0 +1,5418 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + const customFields = { + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // "Reunión agendada" pasa solo para reagendar, cancelar o un NUEVO agendamiento + // (evita re-disparar en mensajes pasivos: "gracias", "ok", smalltalk → siguen en null). + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + })), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió guardar tu contacto para una prueba controlada de IA360. No quiero venderte nada: quiere validar si este flujo de WhatsApp, CRM y memoria tiene sentido técnico. ¿Te puedo dejar una pregunta corta o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 con contactos de confianza y quiere una crítica concreta, no venderte nada. ¿Te puedo dejar una pregunta breve sobre el flujo de WhatsApp, CRM y memoria, o prefieres que Alek te escriba directo?`, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek quiere probar si IA360 puede recordar contexto útil sin volverse invasiva. ¿Te puedo hacer una pregunta corta para validar memoria y seguimiento, o prefieres que Alek lo revise contigo?`, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te tengo registrado como referido de una introducción. Antes de mandarte una propuesta fuera de contexto, Alek quiere entender si tiene sentido hablar de IA360 para tu área o dolor principal. ¿Prefieres una pregunta breve o que Alek te escriba directo?`, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de abrir una conversación larga, Alek quiere darte una versión simple: IA360 ayuda a que WhatsApp, CRM y seguimiento no se caigan entre personas, datos y agenda. ¿Te hace sentido que te deje una pregunta para ver si aplica a tu caso?`, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para cuidar la introducción, no quiero mandarte agenda sin contexto. Si el tema de IA360 te suena útil, ¿te puedo dejar una pregunta breve para saber si conviene que Alek te proponga una llamada?`, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió ubicar si tiene sentido explorar una colaboración alrededor de IA360. La idea no es venderte algo genérico, sino ver si tus clientes suelen tener fricción en WhatsApp, CRM, datos o procesos repetidos. ¿Te hago una pregunta corta para mapear fit?`, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no pedir intros a ciegas, Alek quiere definir qué tipo de cliente sí tendría sentido para IA360. ¿Te puedo preguntar qué señales ves cuando una empresa ya necesita ordenar WhatsApp, CRM, datos o seguimiento?`, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si quieres explicar IA360 sin exponer datos de clientes, Alek puede compartirte un caso NDA-safe: problema, operación antes y resultado esperado. ¿Te serviría para detectar si hay fit con tus clientes?`, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de proponerte algo nuevo, quiero ubicar si hay algún avance, fricción o siguiente paso pendiente en lo que ya estamos trabajando. ¿Quieres que te deje una pregunta breve o prefieres que Alek lo revise contigo?`, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Quiero revisar si algo se atoró antes de hablar de siguientes pasos. ¿Hay una fricción concreta que quieras que Alek vea primero?`, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si lo actual ya está avanzando, Alek quiere ubicar el siguiente punto con más impacto, sin empujar algo fuera de tiempo. ¿El mayor siguiente paso está en WhatsApp, CRM, datos, agenda o seguimiento?`, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para no mandarte una demo genérica, primero quiero ubicar dónde podría haber valor real: operación, ventas, datos o seguimiento. ¿Te dejo una pregunta rápida para detectar el cuello que más mueve la aguja?`, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, normalmente se nota en tiempo perdido, seguimiento que se cae, datos poco confiables o decisiones lentas. ¿Cuál de esas fugas te preocupa más hoy?`, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de solución, Alek puede compartirte un caso NDA-safe con problema, enfoque y resultado esperado. ¿Te serviría como punto de partida?`, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si el problema está en ventas, casi siempre aparece en tres lugares: leads que no llegan, seguimiento que se cae o WhatsApp/CRM sin contexto. ¿Cuál de esos te duele más hoy?`, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y CRM trabajando sin contexto compartido. ¿Hoy qué se pierde más: historial, seguimiento, prioridad o datos para decidir?`, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si IA360 se aplica a prospección, primero hay que saber si existe un segmento claro, un mensaje repetible y seguimiento medible. ¿Qué parte de ese motor está más débil hoy?`, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando finanzas no puede confiar rápido en los datos, la operación termina trabajando a mano. ¿El mayor dolor está en cartera, comisiones, reportes o conciliación?`, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si cartera o datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si revisamos IA360 desde lo técnico, la conversación debe empezar por permisos, datos, trazabilidad y rollback. ¿Quieres que te deje el mapa de integración o prefieres que Alek lo revise contigo?`, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar: permisos, datos, trazabilidad, reversibilidad o dependencia operativa. ¿Cuál revisarías primero?`, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica, debe ser limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que una integración controlada te parezca segura?`, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { + text: `Alek, ${name} quedó como ${flow.personaContext}. Elige una secuencia lógica. Sigo en dry-run: no enviaré nada al contacto.`, + }, + footer: { text: 'Persona antes de secuencia; aprobación antes de envío' }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: flow.sequences.map(seq => ({ + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(seq.goal, 72), + })), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360Bookings(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else { + // ── REAGENDAR (u otra intención no-agendamiento): conserva ack + handoff (Alek + // mueve el evento a mano via la tarea de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Quiero mapa', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Perfecto. Para armar mapa sin humo necesito prioridad real: ¿esto urge, estás explorando o no es prioridad todavía?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 D1 — offer_router: estado dolor+mecanismo+mapa solicitado. REEMPLAZA los botones de + // urgencia (fallback abajo si el envio falla). Replica el patron del diagnostico. + if (flow100m.tag === 'mapa-30-60-90-solicitado') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '2185399508915155', + screen: 'PERFIL_EMPRESA', + cta: 'Ver mi oferta', + bodyText: 'Para darte la oferta correcta y sin humo, contéstame 5 datos rápidos: tamaño de tu empresa, equipo afectado, presupuesto, tu nivel de decisión y qué tipo de solución prefieres.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_offer_router', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] offer_router flow send failed, falling back to buttons:', flowErr.message); + } + } + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + ], + }, + }, + }); + return; + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/backend/src/services/messageSender.js.bak-interactive-audit-20260608T145952 b/backend/src/services/messageSender.js.bak-interactive-audit-20260608T145952 new file mode 100644 index 0000000..3228aa5 --- /dev/null +++ b/backend/src/services/messageSender.js.bak-interactive-audit-20260608T145952 @@ -0,0 +1,124 @@ +// Central outbound orchestration. ALL message-sending paths funnel through here: +// - Chat reply input (routes/messages.js → POST /messages/send) +// - Broadcast launch (routes/broadcasts.js → /:id/send) +// - Automation engine (engine/automationEngine.executeMessageNode) +// - Template test (routes/templates.js → /:id/test-send) +// +// Flow: +// 1. Insert optimistic chat_history row (status='sending', local message_id) +// 2. Enqueue BullMQ job (sendQueue.enqueueSend) +// 3. Worker calls Meta, then updates the row in place: +// success → message_id = real wamid, status = 'sent' +// failure → status = 'failed', error_message populated + +const crypto = require('crypto'); +const pool = require('../db'); +const { getAccountByPhoneNumber, getAccountWithToken, getSingleAccount } = require('../routes/whatsappAccounts'); + +function localMessageId() { + // Distinct from Meta's wamid format so we can tell them apart in logs + return `local-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`; +} + +/** + * Resolve credentials for a sender. Accepts either an explicit accountId or a + * fromPhoneNumber. Returns { account, error } — never throws. + */ +async function resolveAccount({ accountId, fromPhoneNumber }) { + try { + let acc = null; + if (accountId) acc = await getAccountWithToken(accountId); + else if (fromPhoneNumber) acc = await getAccountByPhoneNumber(fromPhoneNumber); + // Single-account product: if matching by id/phone found nothing (e.g. the + // display number isn't resolved from Meta yet), fall back to the lone account. + if (!acc) acc = await getSingleAccount(); + if (!acc) return { error: `No WhatsApp Business account registered for ${fromPhoneNumber || `id=${accountId}`}` }; + if (!acc.isActive) return { error: `WhatsApp Business account "${acc.displayName}" is inactive` }; + if (!acc.accessToken) return { error: 'Access token missing (re-enter in Settings)' }; + return { account: acc }; + } catch (err) { + return { error: err.message || 'Account lookup failed' }; + } +} + +/** + * Insert an optimistic chat_history row that the UI shows as "sending…". + * Returns the local message_id used so caller can correlate later updates. + */ +async function insertPendingRow({ account, toNumber, messageType, messageBody, mediaUrl = null, mediaMime = null, templateMeta = null, contextMessageId = null }) { + const messageId = localMessageId(); + await pool.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, + media_url, media_mime_type, status, timestamp, template_meta, context_message_id) + VALUES ($1,$2,$3,$4,$5,'outgoing',$6,$7,$8,$9,$10,'sending',NOW(),$11,$12)`, + [ + messageId, + account.phoneNumberId, + account.displayPhoneNumber.replace(/\D/g, ''), + String(toNumber).replace(/\D/g, ''), + String(toNumber).replace(/\D/g, ''), + messageType, + messageBody || null, + JSON.stringify({ origin: 'outbound', queued_at: new Date().toISOString() }), + mediaUrl, + mediaMime, + templateMeta ? JSON.stringify(templateMeta) : null, + contextMessageId || null, + ] + ); + return messageId; +} + +/** + * Mark a previously-inserted row as accepted by Meta, swapping in the real wamid. + */ +async function markSent(localId, wamid) { + await pool.query( + `UPDATE coexistence.chat_history + SET message_id = $1, status = 'sent', error_message = NULL + WHERE message_id = $2`, + [wamid, localId] + ); +} + +/** + * Mark a row as failed (Meta rejected, network error, etc). + */ +async function markFailed(localId, errorMessage) { + await pool.query( + `UPDATE coexistence.chat_history + SET status = 'failed', error_message = $1 + WHERE message_id = $2`, + [(errorMessage || 'send failed').slice(0, 500), localId] + ); +} + +/** + * Return seconds-since the last incoming message from `contactNumber` to + * `accountPhoneNumberId`. Returns null if no inbound message exists. + * Meta's "customer service window" is 24h = 86400s. + */ +async function secondsSinceLastIncoming({ accountPhoneNumberId, contactNumber }) { + const norm = String(contactNumber).replace(/\D/g, ''); + const { rows } = await pool.query( + `SELECT EXTRACT(EPOCH FROM (NOW() - MAX(timestamp))) AS seconds + FROM coexistence.chat_history + WHERE phone_number_id = $1 + AND contact_number = $2 + AND direction = 'incoming'`, + [accountPhoneNumberId, norm] + ); + const s = rows[0]?.seconds; + return s != null ? Math.floor(s) : null; +} + +module.exports = { + resolveAccount, + insertPendingRow, + markSent, + markFailed, + secondsSinceLastIncoming, + localMessageId, +}; diff --git a/backend/src/services/messageSender.js.bak-pre-gbrain b/backend/src/services/messageSender.js.bak-pre-gbrain new file mode 100644 index 0000000..caadda9 --- /dev/null +++ b/backend/src/services/messageSender.js.bak-pre-gbrain @@ -0,0 +1,124 @@ +// Central outbound orchestration. ALL message-sending paths funnel through here: +// - Chat reply input (routes/messages.js → POST /messages/send) +// - Broadcast launch (routes/broadcasts.js → /:id/send) +// - Automation engine (engine/automationEngine.executeMessageNode) +// - Template test (routes/templates.js → /:id/test-send) +// +// Flow: +// 1. Insert optimistic chat_history row (status='sending', local message_id) +// 2. Enqueue BullMQ job (sendQueue.enqueueSend) +// 3. Worker calls Meta, then updates the row in place: +// success → message_id = real wamid, status = 'sent' +// failure → status = 'failed', error_message populated + +const crypto = require('crypto'); +const pool = require('../db'); +const { getAccountByPhoneNumber, getAccountWithToken, getSingleAccount } = require('../routes/whatsappAccounts'); + +function localMessageId() { + // Distinct from Meta's wamid format so we can tell them apart in logs + return `local-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`; +} + +/** + * Resolve credentials for a sender. Accepts either an explicit accountId or a + * fromPhoneNumber. Returns { account, error } — never throws. + */ +async function resolveAccount({ accountId, fromPhoneNumber }) { + try { + let acc = null; + if (accountId) acc = await getAccountWithToken(accountId); + else if (fromPhoneNumber) acc = await getAccountByPhoneNumber(fromPhoneNumber); + // Single-account product: if matching by id/phone found nothing (e.g. the + // display number isn't resolved from Meta yet), fall back to the lone account. + if (!acc) acc = await getSingleAccount(); + if (!acc) return { error: `No WhatsApp Business account registered for ${fromPhoneNumber || `id=${accountId}`}` }; + if (!acc.isActive) return { error: `WhatsApp Business account "${acc.displayName}" is inactive` }; + if (!acc.accessToken) return { error: 'Access token missing (re-enter in Settings)' }; + return { account: acc }; + } catch (err) { + return { error: err.message || 'Account lookup failed' }; + } +} + +/** + * Insert an optimistic chat_history row that the UI shows as "sending…". + * Returns the local message_id used so caller can correlate later updates. + */ +async function insertPendingRow({ account, toNumber, messageType, messageBody, mediaUrl = null, mediaMime = null, templateMeta = null, contextMessageId = null, rawPayloadExtra = null }) { + const messageId = localMessageId(); + await pool.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, + media_url, media_mime_type, status, timestamp, template_meta, context_message_id) + VALUES ($1,$2,$3,$4,$5,'outgoing',$6,$7,$8,$9,$10,'sending',NOW(),$11,$12)`, + [ + messageId, + account.phoneNumberId, + account.displayPhoneNumber.replace(/\D/g, ''), + String(toNumber).replace(/\D/g, ''), + String(toNumber).replace(/\D/g, ''), + messageType, + messageBody || null, + JSON.stringify({ origin: 'outbound', queued_at: new Date().toISOString(), ...(rawPayloadExtra ? { interactive: rawPayloadExtra } : {}) }), + mediaUrl, + mediaMime, + templateMeta ? JSON.stringify(templateMeta) : null, + contextMessageId || null, + ] + ); + return messageId; +} + +/** + * Mark a previously-inserted row as accepted by Meta, swapping in the real wamid. + */ +async function markSent(localId, wamid) { + await pool.query( + `UPDATE coexistence.chat_history + SET message_id = $1, status = 'sent', error_message = NULL + WHERE message_id = $2`, + [wamid, localId] + ); +} + +/** + * Mark a row as failed (Meta rejected, network error, etc). + */ +async function markFailed(localId, errorMessage) { + await pool.query( + `UPDATE coexistence.chat_history + SET status = 'failed', error_message = $1 + WHERE message_id = $2`, + [(errorMessage || 'send failed').slice(0, 500), localId] + ); +} + +/** + * Return seconds-since the last incoming message from `contactNumber` to + * `accountPhoneNumberId`. Returns null if no inbound message exists. + * Meta's "customer service window" is 24h = 86400s. + */ +async function secondsSinceLastIncoming({ accountPhoneNumberId, contactNumber }) { + const norm = String(contactNumber).replace(/\D/g, ''); + const { rows } = await pool.query( + `SELECT EXTRACT(EPOCH FROM (NOW() - MAX(timestamp))) AS seconds + FROM coexistence.chat_history + WHERE phone_number_id = $1 + AND contact_number = $2 + AND direction = 'incoming'`, + [accountPhoneNumberId, norm] + ); + const s = rows[0]?.seconds; + return s != null ? Math.floor(s) : null; +} + +module.exports = { + resolveAccount, + insertPendingRow, + markSent, + markFailed, + secondsSinceLastIncoming, + localMessageId, +}; diff --git a/backend/webhook.js.bak-pre-gwin-20260610175408 b/backend/webhook.js.bak-pre-gwin-20260610175408 new file mode 100644 index 0000000..fc11792 --- /dev/null +++ b/backend/webhook.js.bak-pre-gwin-20260610175408 @@ -0,0 +1,7633 @@ +const { Router } = require('express'); +const pool = require('../db'); +const { decrypt } = require('../util/crypto'); +const { safeEqual, verifyMetaSignature } = require('../util/webhookSignature'); +const { evaluateTriggers, resumeAutomation } = require('../engine/automationEngine'); +const { markPending, MEDIA_TYPES } = require('../services/mediaDownloader'); +const { enqueueMediaDownload } = require('../queue/mediaQueue'); +const { resolveAccount, insertPendingRow, secondsSinceLastIncoming } = require('../services/messageSender'); +const { enqueueSend } = require('../queue/sendQueue'); +const { getIa360StageForEvent, getIa360StageForReply } = require('../services/ia360Mapping'); + +const router = Router(); + +/** + * Parse a Meta WhatsApp Cloud API webhook payload and extract message records. + * Handles: text, image, video, audio, document, location, sticker, contacts, + * interactive (button_reply / list_reply), reaction, and status updates. + */ +// Normalize WhatsApp phone numbers to digits-only — strips '+', spaces, dashes. +// Meta sometimes includes leading '+' in display_phone_number, sometimes not; +// without this, the same conversation lands under two different wa_numbers and +// shows as duplicate chat threads. +function normalizePhone(s) { + if (!s) return s; + return String(s).replace(/\D/g, ''); +} + +function parseMetaPayload(body) { + const records = []; + + if (!body || body.object !== 'whatsapp_business_account') { + return records; + } + + const entries = body.entry || []; + for (const entry of entries) { + const changes = entry.changes || []; + for (const change of changes) { + const value = change.value || {}; + if (value.messaging_product !== 'whatsapp') continue; + + const metadata = value.metadata || {}; + const phoneNumberId = metadata.phone_number_id || ''; + const displayPhoneNumber = metadata.display_phone_number || ''; + + // Contact profile info (name mapping) + const contactProfiles = {}; + (value.contacts || []).forEach(c => { + const waId = c.wa_id || ''; + const name = c.profile?.name || ''; + if (waId && name) contactProfiles[waId] = name; + }); + + // Parse a single message (shared logic for incoming and outgoing) + function parseMessage(msg, direction, waNum, contactNum) { + const record = { + message_id: msg.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(waNum || displayPhoneNumber), + contact_number: normalizePhone(contactNum || ''), + to_number: normalizePhone(msg.to || ''), + direction, + message_type: msg.type || 'unknown', + message_body: null, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: direction === 'incoming' ? 'received' : 'sent', + timestamp: msg.timestamp + ? new Date(parseInt(msg.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[contactNum] || null, + // Quote-reply: when the customer replies to a specific message, Meta + // sends the quoted message's wamid here. Stored so we can render the + // quoted bubble above their reply. + context_message_id: msg.context?.id || null, + }; + + const type = msg.type; + if (type === 'text' && msg.text) { + record.message_body = msg.text.body || ''; + } else if (type === 'image' && msg.image) { + record.message_body = msg.image.caption || ''; + record.media_mime_type = msg.image.mime_type || null; + record.media_url = msg.image.id || null; + } else if (type === 'video' && msg.video) { + record.message_body = msg.video.caption || ''; + record.media_mime_type = msg.video.mime_type || null; + record.media_url = msg.video.id || null; + } else if (type === 'audio' && msg.audio) { + record.message_body = 'Audio message'; + record.media_mime_type = msg.audio.mime_type || null; + record.media_url = msg.audio.id || null; + } else if (type === 'voice' && msg.voice) { + record.message_body = 'Voice message'; + record.media_mime_type = msg.voice.mime_type || null; + record.media_url = msg.voice.id || null; + } else if (type === 'document' && msg.document) { + record.message_body = msg.document.filename || ''; + record.media_mime_type = msg.document.mime_type || null; + record.media_url = msg.document.id || null; + record.media_filename = msg.document.filename || null; + } else if (type === 'location' && msg.location) { + const lat = msg.location.latitude || ''; + const lng = msg.location.longitude || ''; + record.message_body = `Location: ${lat}, ${lng}`; + } else if (type === 'sticker' && msg.sticker) { + record.message_body = 'Sticker'; + record.media_mime_type = msg.sticker.mime_type || null; + record.media_url = msg.sticker.id || null; + } else if (type === 'contacts' && msg.contacts) { + const names = msg.contacts.map(c => c.name?.formatted_name || c.name?.first_name || 'Contact').join(', '); + record.message_body = `Shared contact(s): ${names}`; + } else if (type === 'interactive' && msg.interactive) { + const reply = msg.interactive.button_reply || msg.interactive.list_reply || {}; + record.message_body = reply.title || 'Interactive response'; + record.message_type = 'interactive'; + } else if (type === 'button' && msg.button) { + record.message_body = msg.button.text || msg.button.payload || 'Button response'; + record.message_type = 'button'; + } else if (type === 'reaction' && msg.reaction) { + record.message_body = `Reaction: ${msg.reaction.emoji || ''}`; + record.message_type = 'reaction'; + // Capture the target message + emoji so the insert loop can attach it + // to that message instead of creating a standalone bubble. Empty emoji + // = the customer removed their reaction. + record.reaction = { + targetMessageId: msg.reaction.message_id || null, + emoji: msg.reaction.emoji || '', + from: msg.from || null, + }; + } else if (type === 'order' && msg.order) { + record.message_body = 'Order received'; + } else if (type === 'system' && msg.system) { + record.message_body = msg.system.body || 'System message'; + } else if (type === 'unknown' && msg.errors) { + record.message_body = `Error: ${msg.errors[0]?.message || 'Unknown error'}`; + record.status = 'error'; + } + + return record; + } + + // Incoming messages + const messages = value.messages || []; + for (const msg of messages) { + records.push(parseMessage(msg, 'incoming', displayPhoneNumber, msg.from)); + } + + // Outgoing message echoes (messages sent from the WhatsApp Business app) + const messageEchoes = value.message_echoes || []; + for (const msg of messageEchoes) { + // For echoes: from = business number, to = customer + records.push(parseMessage(msg, 'outgoing', displayPhoneNumber, msg.to)); + } + + // Status updates (delivered, read, sent) + const statuses = value.statuses || []; + for (const status of statuses) { + records.push({ + message_id: status.id || '', + phone_number_id: phoneNumberId, + wa_number: normalizePhone(displayPhoneNumber), + contact_number: normalizePhone(status.recipient_id || ''), + to_number: normalizePhone(status.recipient_id || ''), + direction: 'outgoing', + message_type: 'status', + message_body: `Status: ${status.status || ''}`, + raw_payload: JSON.stringify(body), + media_url: null, + media_mime_type: null, + status: status.status || 'unknown', + timestamp: status.timestamp + ? new Date(parseInt(status.timestamp, 10) * 1000).toISOString() + : new Date().toISOString(), + contact_name: contactProfiles[status.recipient_id] || null, + // Include full status payload for trigger evaluation + conversation: status.conversation || null, + pricing: status.pricing || null, + errors: status.errors || null, + }); + } + } + } + + return records; +} + +async function mergeContactIa360State({ waNumber, contactNumber, tags = [], customFields = {} }) { + if (!waNumber || !contactNumber) return; + await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, tags, custom_fields, updated_at) + VALUES ($1, $2, $3::jsonb, $4::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW()`, + [waNumber, contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); +} + +let ia360MemoryTablesReady = null; +const IA360_MEMORY_EGRESS_ON = process.env.IA360_MEMORY_EGRESS === 'on'; + +async function ensureIa360MemoryTables() { + if (!ia360MemoryTablesReady) { + ia360MemoryTablesReady = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_events ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_event.v1', + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + contact_name TEXT, + contact_role TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + lifecycle_stage TEXT, + area TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + summary TEXT NOT NULL, + business_impact TEXT, + missing_data TEXT, + next_action TEXT, + should_be_fact BOOLEAN NOT NULL DEFAULT false, + crm_sync_status TEXT NOT NULL DEFAULT 'dry_run_compact', + rag_index_status TEXT NOT NULL DEFAULT 'structured_lookup_ready', + owner_review_status TEXT NOT NULL DEFAULT 'required', + external_send_allowed BOOLEAN NOT NULL DEFAULT false, + contains_sensitive_data BOOLEAN NOT NULL DEFAULT false, + store_transcript BOOLEAN NOT NULL DEFAULT false, + source_message_id TEXT, + source_chat_history_id BIGINT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ia360_memory_events_source_area_uidx + ON coexistence.ia360_memory_events (source_message_id, area, signal_type) + WHERE source_message_id IS NOT NULL + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_contact_idx + ON coexistence.ia360_memory_events (contact_wa_number, contact_number, created_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_events_project_area_idx + ON coexistence.ia360_memory_events (project_name, area, created_at DESC) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.ia360_memory_facts ( + id BIGSERIAL PRIMARY KEY, + schema_version TEXT NOT NULL DEFAULT 'ia360_memory_fact.v1', + fact_key TEXT NOT NULL UNIQUE, + source_event_id BIGINT REFERENCES coexistence.ia360_memory_events(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'whatsapp', + contact_wa_number TEXT, + contact_number TEXT, + forgechat_contact_id BIGINT, + espo_contact_id TEXT, + account_name TEXT, + project_name TEXT, + persona TEXT, + role TEXT, + preference TEXT, + objection TEXT, + recurring_pain TEXT, + affected_process TEXT, + missing_metric TEXT, + confidence NUMERIC(4,3) NOT NULL DEFAULT 0.650, + owner_review_status TEXT NOT NULL DEFAULT 'pending_owner_review', + status TEXT NOT NULL DEFAULT 'pending_owner_review', + evidence_count INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_contact_idx + ON coexistence.ia360_memory_facts (contact_wa_number, contact_number, last_seen_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ia360_memory_facts_project_area_idx + ON coexistence.ia360_memory_facts (project_name, affected_process, last_seen_at DESC) + `); + })().catch(err => { + ia360MemoryTablesReady = null; + throw err; + }); + } + return ia360MemoryTablesReady; +} + +function stripSensitiveIa360Text(text, max = 220) { + return String(text || '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/\+?\d[\d\s().-]{7,}\d/g, '[phone]') + .replace(/\b(?:sk|pk|rk|org|proj)-[A-Za-z0-9_-]{16,}\b/g, '[secret]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function buildIa360CrmCompactNote({ record, agent }) { + const parts = [ + `canal=whatsapp`, + `tipo=${record?.message_type || 'text'}`, + `accion=${agent?.action || 'reply'}`, + `intent=${agent?.intent || 'unknown'}`, + ]; + if (agent?.extracted?.area_operacion) parts.push(`area=${stripSensitiveIa360Text(agent.extracted.area_operacion, 80)}`); + if (agent?.extracted?.dolor) parts.push(`dolor=${stripSensitiveIa360Text(agent.extracted.dolor, 120)}`); + return parts.join('; '); +} + +async function loadIa360ContactContext(record) { + if (!record?.wa_number || !record?.contact_number) return null; + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name) AS name, tags, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + return rows[0] || null; +} + +function isIa360ClienteActivoBetaContact(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const tags = Array.isArray(contact?.tags) ? contact.tags.map(t => String(t || '').toLowerCase()) : []; + return beta.schema === 'cliente_activo_beta.v1' + || beta.contact_role === 'cliente_activo_cfo_champion' + || cf.lifecycle_stage === 'cliente_activo_beta_supervisado' + || cf.relationship_context === 'cliente_activo_beta_supervisado' + || tags.includes('cliente-activo-beta'); +} + +function getIa360ContactProfile(contact) { + const cf = contact?.custom_fields || {}; + const beta = cf.ia360_cliente_activo_beta || {}; + const personaFirst = cf.ia360_persona_first || {}; + return { + forgechatContactId: contact?.id || null, + espoContactId: cf.espo_id || '', + name: contact?.name || '', + role: beta.contact_role || cf.project_role || cf.rol_comite || '', + accountName: cf.account_name || cf.empresa || beta.project || cf.project_name || '', + projectName: beta.project || cf.project_name || cf.account_name || '', + persona: cf.persona_principal || personaFirst?.classification?.persona_context || '', + lifecycleStage: cf.lifecycle_stage || beta.flywheel_phase || cf.flywheel_phase || '', + }; +} + +const IA360_MEMORY_SIGNAL_CATALOG = [ + { + area: 'cartera_cobranza_portal', + label: 'cartera/cobranza portal', + signalType: 'dolor_operativo', + regex: /cartera|cobran[cz]a|cuentas? por cobrar|portal|excel|comentarios?|fecha(?:s)? compromiso|promesa de pago|seguimiento de pago/i, + summary: 'Cobranza necesita comentarios, fechas compromiso, pasos internos y seguimiento visibles en portal, no dispersos en Excel o llamadas.', + businessImpact: 'Reduce fuga de seguimiento y mejora visibilidad financiera de cartera.', + missingData: 'Cuenta o cliente, fecha compromiso, responsable, siguiente paso y estado actual.', + nextAction: 'Mapear cartera -> comentario -> compromiso -> responsable -> seguimiento.', + affectedProcess: 'cartera -> comentario -> compromiso -> responsable -> seguimiento', + missingMetric: 'fecha compromiso y responsable por cuenta', + confidence: 0.88, + }, + { + area: 'taller_garantia_dias_detencion', + label: 'taller/garantía/días detenidos', + signalType: 'dolor_operativo', + regex: /taller|garant[ií]a|unidad(?:es)?|cami[oó]n|detenid|parad[ao]|refacci[oó]n|bloqueo|escalaci[oó]n|d[ií]as/i, + summary: 'Taller necesita visibilidad de días de unidad detenida, bloqueo, responsable y criterio de escalación.', + businessImpact: 'Una decisión de garantía o proceso puede costar más que resolver rápido si la unidad deja de operar.', + missingData: 'Unidad, días detenida, tipo de caso, bloqueo, responsable y costo operativo estimado.', + nextAction: 'Mapear unidad detenida -> bloqueo -> responsable -> decisión -> costo para cliente.', + affectedProcess: 'unidad detenida -> bloqueo -> responsable -> decisión', + missingMetric: 'días detenida por unidad y costo operativo', + confidence: 0.90, + }, + { + area: 'auditoria_licencias_gasto', + label: 'auditoría de licencias/gasto', + signalType: 'dolor_operativo', + regex: /licencias?|asientos?|usuarios?|gasto|software|suscripci[oó]n|uso real|permisos?|consultas?|consulta con ia/i, + summary: 'Se necesita comparar licencias pagadas contra uso real y evaluar si IA concentra consultas sin comprar asientos innecesarios.', + businessImpact: 'Puede bajar gasto recurrente sin perder acceso operativo a información.', + missingData: 'Sistema, costo por licencia, usuarios activos, consultas necesarias y permisos mínimos.', + nextAction: 'Mapear licencia pagada -> uso real -> consulta necesaria -> permiso -> ahorro posible.', + affectedProcess: 'licencias -> uso real -> consultas -> permisos -> ahorro', + missingMetric: 'usuarios activos contra licencias pagadas', + confidence: 0.86, + }, + { + area: 'feedback_asistente', + label: 'feedback del asistente', + signalType: 'feedback', + regex: /no sirve|eso no|mal|incorrect|equivoc|no ayuda|no entend|deber[ií]a|prefiero que responda|respuesta mala/i, + summary: 'El cliente está corrigiendo la respuesta del asistente y deja aprendizaje sobre cómo debe contestar.', + businessImpact: 'Mejora criterio del asistente y evita insistir con respuestas que no ayudan.', + missingData: 'Qué parte falló y cuál sería la respuesta preferida.', + nextAction: 'Registrar motivo del fallo, ajustar criterio y pedir ejemplo de respuesta útil.', + affectedProcess: 'calidad de respuesta beta', + missingMetric: 'motivo de fallo y respuesta esperada', + confidence: 0.84, + }, +]; + +function isPassiveIa360Text(text) { + return /^(ok|okay|gracias|va|sale|listo|perfecto|sí|si|no|👍|👌)[\s.!¡!¿?]*$/i.test(String(text || '').trim()); +} + +function extractIa360MemorySignals({ record, contact, agent = {} }) { + const text = String(record?.message_body || '').trim(); + if (!text || isPassiveIa360Text(text)) return []; + const matches = IA360_MEMORY_SIGNAL_CATALOG.filter(item => item.regex.test(text)); + if (matches.length) return matches.map(item => ({ ...item, shouldBeFact: true })); + const extracted = agent?.extracted || {}; + const hasAgentLearning = agent?.action === 'advance_pain' + || agent?.intent === 'ask_pain' + || extracted.area_operacion + || extracted.dolor; + if (!hasAgentLearning && text.length < 32) return []; + if (!isIa360ClienteActivoBetaContact(contact) && !hasAgentLearning) return []; + return [{ + area: extracted.area_operacion ? stripSensitiveIa360Text(extracted.area_operacion, 80).toLowerCase().replace(/[^a-z0-9_]+/g, '_') : 'operacion_cliente', + label: 'operación cliente', + signalType: extracted.dolor ? 'dolor_operativo' : 'senal_operativa', + summary: extracted.dolor + ? stripSensitiveIa360Text(extracted.dolor, 180) + : 'El contacto dejó una señal operativa que requiere revisión de Alek antes de convertirla en acción.', + businessImpact: 'Impacto pendiente de precisar con dato operativo y responsable.', + missingData: 'Dato actual, bloqueo, responsable y siguiente acción.', + nextAction: 'Convertir la señal en mapa dato actual -> bloqueo -> responsable -> siguiente acción.', + affectedProcess: 'operación cliente', + missingMetric: 'dato operativo mínimo', + confidence: hasAgentLearning ? 0.70 : 0.55, + shouldBeFact: Boolean(extracted.dolor || isIa360ClienteActivoBetaContact(contact)), + }]; +} + +function buildIa360MemoryEventPayload({ record, contact, signal }) { + const profile = getIa360ContactProfile(contact); + const payload = { + schema: 'ia360_memory_event.v1', + source: 'whatsapp', + contact: { + forgechat_contact_id: profile.forgechatContactId || '', + espo_contact_id: profile.espoContactId || '', + name: profile.name || record?.contact_name || '', + role: profile.role || '', + }, + account: { + name: profile.accountName || '', + project: profile.projectName || '', + }, + classification: { + persona: profile.persona || '', + lifecycle_stage: profile.lifecycleStage || '', + area: signal.area, + signal_type: signal.signalType, + confidence: signal.confidence, + }, + learning: { + summary: signal.summary, + business_impact: signal.businessImpact, + missing_data: signal.missingData, + next_action: signal.nextAction, + should_be_fact: Boolean(signal.shouldBeFact), + }, + sync: { + crm_sync_status: 'dry_run_compact', + rag_index_status: 'structured_lookup_ready', + owner_review_status: 'required', + }, + guardrails: { + external_send_allowed: false, + contains_sensitive_data: false, + store_transcript: false, + }, + }; + return { + payload, + profile, + }; +} + +async function persistIa360MemorySignal({ record, contact, signal }) { + await ensureIa360MemoryTables(); + const { payload, profile } = buildIa360MemoryEventPayload({ record, contact, signal }); + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_memory_events ( + schema_version, source, contact_wa_number, contact_number, forgechat_contact_id, + espo_contact_id, contact_name, contact_role, account_name, project_name, + persona, lifecycle_stage, area, signal_type, confidence, summary, + business_impact, missing_data, next_action, should_be_fact, + crm_sync_status, rag_index_status, owner_review_status, external_send_allowed, + contains_sensitive_data, store_transcript, source_message_id, payload + ) + VALUES ( + 'ia360_memory_event.v1', 'whatsapp', $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, + 'dry_run_compact', 'structured_lookup_ready', 'required', false, + false, false, $19, $20::jsonb + ) + ON CONFLICT (source_message_id, area, signal_type) WHERE source_message_id IS NOT NULL + DO UPDATE SET + summary=EXCLUDED.summary, + business_impact=EXCLUDED.business_impact, + missing_data=EXCLUDED.missing_data, + next_action=EXCLUDED.next_action, + payload=EXCLUDED.payload, + updated_at=NOW() + RETURNING id`, + [ + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.name || record.contact_name || null, + profile.role || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.lifecycleStage || null, + signal.area, + signal.signalType, + signal.confidence, + signal.summary, + signal.businessImpact, + signal.missingData, + signal.nextAction, + Boolean(signal.shouldBeFact), + record.message_id || null, + JSON.stringify(payload), + ] + ); + const eventId = rows[0]?.id || null; + let factId = null; + if (signal.shouldBeFact) { + const factKey = [ + record.wa_number || '', + record.contact_number || '', + profile.projectName || '', + signal.area, + signal.signalType, + ].join(':').toLowerCase(); + const factPayload = { + schema: 'ia360_memory_fact.v1', + persona: profile.persona || '', + role: profile.role || '', + preference: isIa360ClienteActivoBetaContact(contact) + ? 'Responder con hallazgo, impacto, dato faltante y siguiente acción; no pitch ni agenda por default.' + : '', + objection: signal.signalType === 'feedback' ? signal.summary : '', + recurring_pain: signal.summary, + affected_process: signal.affectedProcess, + missing_metric: signal.missingMetric, + source: record.message_id || 'whatsapp', + confidence: signal.confidence, + }; + const fact = await pool.query( + `INSERT INTO coexistence.ia360_memory_facts ( + schema_version, fact_key, source_event_id, source, contact_wa_number, + contact_number, forgechat_contact_id, espo_contact_id, account_name, + project_name, persona, role, preference, objection, recurring_pain, + affected_process, missing_metric, confidence, owner_review_status, + status, payload + ) + VALUES ( + 'ia360_memory_fact.v1', md5($1), $2, 'whatsapp', $3, + $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, 'pending_owner_review', 'pending_owner_review', $17::jsonb + ) + ON CONFLICT (fact_key) + DO UPDATE SET + source_event_id=COALESCE(EXCLUDED.source_event_id, coexistence.ia360_memory_facts.source_event_id), + preference=COALESCE(NULLIF(EXCLUDED.preference, ''), coexistence.ia360_memory_facts.preference), + objection=COALESCE(NULLIF(EXCLUDED.objection, ''), coexistence.ia360_memory_facts.objection), + recurring_pain=EXCLUDED.recurring_pain, + missing_metric=EXCLUDED.missing_metric, + evidence_count=coexistence.ia360_memory_facts.evidence_count + 1, + last_seen_at=NOW(), + updated_at=NOW(), + payload=EXCLUDED.payload + RETURNING id`, + [ + factKey, + eventId, + record.wa_number || null, + record.contact_number || null, + profile.forgechatContactId, + profile.espoContactId || null, + profile.accountName || null, + profile.projectName || null, + profile.persona || null, + profile.role || null, + factPayload.preference, + factPayload.objection, + factPayload.recurring_pain, + factPayload.affected_process, + factPayload.missing_metric, + signal.confidence, + JSON.stringify(factPayload), + ] + ); + factId = fact.rows[0]?.id || null; + } + return { eventId, factId, payload }; +} + +async function persistIa360MemorySignals({ record, contact, signals }) { + const results = []; + for (const signal of signals || []) { + try { + results.push(await persistIa360MemorySignal({ record, contact, signal })); + } catch (err) { + console.error('[ia360-memory] persist error:', err.message); + } + } + return results; +} + +async function lookupIa360MemoryContext({ record, contact, limit = 8 }) { + await ensureIa360MemoryTables(); + const profile = getIa360ContactProfile(contact); + const params = [ + record?.wa_number || null, + record?.contact_number || null, + profile.projectName || null, + limit, + ]; + const events = await pool.query( + `SELECT area, signal_type, summary, business_impact, missing_data, next_action, owner_review_status, created_at + FROM coexistence.ia360_memory_events + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY created_at DESC + LIMIT $4`, + params + ); + const facts = await pool.query( + `SELECT persona, role, preference, objection, recurring_pain, affected_process, missing_metric, confidence, status, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE ((contact_wa_number=$1 AND contact_number=$2) + OR ($3::text IS NOT NULL AND project_name=$3)) + ORDER BY last_seen_at DESC + LIMIT $4`, + params + ); + return { events: events.rows, facts: facts.rows }; +} + +function uniqueIa360Areas(memoryContext, signals = []) { + const areas = new Map(); + for (const signal of signals) areas.set(signal.area, signal.label || signal.area); + for (const event of memoryContext?.events || []) areas.set(event.area, event.area); + for (const fact of memoryContext?.facts || []) { + if (fact.affected_process) areas.set(fact.affected_process, fact.affected_process); + } + return [...areas.values()].slice(0, 5); +} + +function buildIa360ClienteActivoBetaReply({ signals, memoryContext }) { + const primary = signals[0]; + const areas = uniqueIa360Areas(memoryContext, signals); + if (signals.length > 1 || areas.length >= 3) { + return [ + 'Ya dejé registrados los frentes para Alek.', + '', + `Lo que veo: ${areas.join('; ')}.`, + '', + 'Para aterrizarlo sin dispersarnos, elegiría uno y lo bajaría a: dato actual -> bloqueo -> responsable -> siguiente acción.', + '', + '¿Cuál quieres que prioricemos primero?' + ].join('\n'); + } + return [ + `Ya lo registré como ${primary.label || primary.area}.`, + '', + `Hallazgo: ${primary.summary}`, + '', + `Impacto: ${primary.businessImpact}`, + '', + `Dato faltante: ${primary.missingData}`, + '', + `Siguiente acción: ${primary.nextAction}`, + '', + '¿Quieres que prioricemos esto o lo dejo como frente secundario para Alek?' + ].join('\n'); +} + +function maskIa360Number(number) { + const s = String(number || ''); + if (!s) return 'sin número'; + return `${s.slice(0, 3)}***${s.slice(-2)}`; +} + +function buildIa360OwnerMemoryReadout({ record, signals, persisted }) { + const lines = [ + 'Readout IA360 memoria', + '', + `Contacto: ${record.contact_name || 'contacto'} (${maskIa360Number(record.contact_number)})`, + `Eventos guardados: ${persisted.filter(x => x.eventId).length}`, + `Facts propuestos: ${persisted.filter(x => x.factId).length}`, + '', + 'Señales:', + ...signals.map(s => `- ${s.label || s.area}: ${s.nextAction}`), + '', + 'Guardrail: no envié pitch, no creé oportunidad y CRM queda en dry-run compacto.' + ]; + return lines.join('\n'); +} + +async function handleIa360ClienteActivoBetaLearning({ record, deal, contact }) { + const memoryBefore = await lookupIa360MemoryContext({ record, contact }).catch(err => { + console.error('[ia360-memory] lookup before reply:', err.message); + return { events: [], facts: [] }; + }); + const signals = extractIa360MemorySignals({ record, contact, agent: { action: 'cliente_activo_beta_learning' } }); + if (!signals.length) return false; + const persisted = await persistIa360MemorySignals({ record, contact, signals }); + const memoryAfter = await lookupIa360MemoryContext({ record, contact }).catch(() => memoryBefore); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-memory', 'cliente-activo-beta'], + customFields: { + ia360_memory_last_event_at: new Date().toISOString(), + ia360_memory_last_areas: signals.map(s => s.area), + ia360_memory_last_lookup_count: (memoryAfter.events || []).length + (memoryAfter.facts || []).length, + ia360_cliente_activo_beta_last_reply_kind: 'memory_learning', + }, + }).catch(e => console.error('[ia360-memory] contact marker:', e.message)); + const reply = buildIa360ClienteActivoBetaReply({ signals, memoryContext: memoryAfter }); + const ownerReadout = buildIa360OwnerMemoryReadout({ record, signals, persisted }); + if (!IA360_MEMORY_EGRESS_ON) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { + ia360_memory_egress: 'dry_run', + ia360_memory_last_reply_preview: reply, + ia360_memory_last_owner_readout_preview: ownerReadout, + }, + }).catch(e => console.error('[ia360-memory] dry-run marker:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s egress=dry_run', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; + } + await enqueueIa360Text({ record, label: 'ia360_cliente_activo_beta_memory_reply', body: reply }); + sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_ia360_memory_readout', + body: ownerReadout, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-memory] owner readout:', e.message)); + console.log('[ia360-memory] contact=%s mode=%s events=%d facts=%d stage=%s', + maskIa360Number(record.contact_number), + deal?.memory_mode || 'cliente_activo_beta', + persisted.filter(x => x.eventId).length, + persisted.filter(x => x.factId).length, + deal?.stage_name || '-' + ); + return true; +} + +function extractSharedContactsFromRecord(record) { + if (!record || record.message_type !== 'contacts' || !record.raw_payload) return []; + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const found = new Map(); + const entries = Array.isArray(payload?.entry) ? payload.entry : []; + for (const entry of entries) { + const changes = Array.isArray(entry?.changes) ? entry.changes : []; + for (const change of changes) { + const messages = Array.isArray(change?.value?.messages) ? change.value.messages : []; + for (const msg of messages) { + if (record.message_id && msg?.id && msg.id !== record.message_id) continue; + const contacts = Array.isArray(msg?.contacts) ? msg.contacts : []; + for (const c of contacts) { + const phones = Array.isArray(c?.phones) ? c.phones : []; + const primaryPhone = phones.find(p => p?.wa_id) || phones.find(p => p?.phone) || {}; + const contactNumber = normalizePhone(primaryPhone.wa_id || primaryPhone.phone || ''); + if (!contactNumber) continue; + const emails = Array.isArray(c?.emails) ? c.emails : []; + const name = c?.name?.formatted_name || c?.name?.first_name || c?.name?.last_name || contactNumber; + found.set(contactNumber, { + contactNumber, + name, + phoneRaw: primaryPhone.phone || null, + waId: primaryPhone.wa_id || null, + email: emails[0]?.email || null, + raw: c, + }); + } + } + } + } + return [...found.values()]; + } catch (err) { + console.error('[ia360-vcard] extract error:', err.message); + return []; + } +} + +function inferIa360QaPersonaHint(name) { + const text = String(name || '').toLowerCase(); + if (!text.startsWith('qa personafirst')) return null; + if (/\baliado\b|\bsocio\b/.test(text)) return 'persona_aliado'; + if (/\bbeta\b|\bamigo\b/.test(text)) return 'persona_beta'; + if (/\breferido\b|\bbni\b/.test(text)) return 'persona_referido'; + if (/\bcliente\b/.test(text)) return 'persona_cliente'; + if (/\bsponsor\b/.test(text)) return 'persona_sponsor'; + if (/\bcomercial\b|\bdirector\b/.test(text)) return 'persona_comercial'; + if (/\bcfo\b|\bfinanzas\b/.test(text)) return 'persona_cfo'; + if (/\btecnico\b|\bt[eé]cnico\b|\bguardian\b|\bguardi[aá]n\b/.test(text)) return 'persona_tecnico'; + if (/\bsolo\b.*\bguardar\b|\bguardar\b/.test(text)) return 'guardar'; + if (/\bno\b.*\bcontactar\b|\bexcluir\b/.test(text)) return 'excluir'; + return null; +} + +// G-C: el nombre del introductor viene de push name / vCard (texto controlado por +// el remitente). Se sanitiza antes de persistir: sin caracteres de control ni +// saltos de línea, sin llaves de placeholder, espacios colapsados y tope de 60 +// caracteres. Devuelve null si no queda nada usable. +function sanitizeIa360IntroName(raw) { + const clean = String(raw || '') + .replace(/[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ' ') + .replace(/[{}]/g, '') + .replace(/\s+/g, ' ') + .trim(); + // Corte por code points (no por unidades UTF-16): un emoji en la frontera de + // los 60 caracteres no deja un surrogate suelto que rompa el jsonb al persistir. + const capped = Array.from(clean).slice(0, 60).join('').trim(); + if (!capped) return null; + if (!/[\p{L}]/u.test(capped)) return null; // sin letras (solo dígitos/símbolos) no sirve como nombre + return capped; +} + +async function upsertIa360SharedContact({ record, shared }) { + if (!record?.wa_number || !shared?.contactNumber) return null; + const qaPersonaExpectedChoice = inferIa360QaPersonaHint(shared.name); + // quien_intro (D6): si el vCard lo comparte un CONTACTO (no el owner), esa + // persona es quien hizo la introducción. Se guarda el NOMBRE para que el + // opener referido_contexto pueda decir "nos presentó X". Si lo manda el owner, + // el dato queda pendiente (el placeholder {{quien_intro}} bloquea el copy). + // G-C: sanitizado (push name inyectable), sin auto-introducción (vCard propio) + // y sin pisar un quien_intro ya capturado. + let quienIntro = null; + const sharerIsSelf = normalizePhone(record.contact_number || '') === normalizePhone(shared.contactNumber || ''); + if (record.contact_number && !sharerIsSelf && normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + try { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(record.contact_number)] + ); + quienIntro = sanitizeIa360IntroName(introRows[0]?.name || introRows[0]?.profile_name || record.contact_name || ''); + if (quienIntro) { + const { rows: existingRows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, normalizePhone(shared.contactNumber)] + ); + if (String(existingRows[0]?.quien_intro || '').trim()) quienIntro = null; // ya hay introductor registrado: no pisar + } + } catch (e) { + console.error('[ia360-vcard] quien_intro lookup:', e.message); + quienIntro = null; + } + } + const customFields = { + ...(quienIntro ? { quien_intro: quienIntro } : {}), + staged: true, + stage: 'Capturado / Por rutear', + captured_at: new Date().toISOString(), + intake_source: 'b29-vcard-whatsapp', + source_message_id: record.message_id || null, + referido_por: record.contact_number || null, + captured_by: normalizePhone(record.contact_number) === IA360_OWNER_NUMBER ? 'owner-whatsapp' : 'whatsapp-contact', + pipeline_sugerido: null, + vcard_phone_raw: shared.phoneRaw || null, + vcard_wa_id: shared.waId || null, + email: shared.email || null, + ...(qaPersonaExpectedChoice ? { qa_persona_expected_choice: qaPersonaExpectedChoice } : {}), + }; + const tags = ['ia360-vcard', 'owner-intake', 'staged']; + const { rows } = await pool.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, name, profile_name, tags, custom_fields, updated_at) + VALUES ($1, $2, $3, $3, $4::jsonb, $5::jsonb, NOW()) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + name = COALESCE(EXCLUDED.name, coexistence.contacts.name), + profile_name = COALESCE(EXCLUDED.profile_name, coexistence.contacts.profile_name), + tags = ( + SELECT COALESCE(jsonb_agg(DISTINCT value), '[]'::jsonb) + FROM jsonb_array_elements_text( + COALESCE(coexistence.contacts.tags, '[]'::jsonb) || EXCLUDED.tags + ) AS value + ), + custom_fields = COALESCE(coexistence.contacts.custom_fields, '{}'::jsonb) || EXCLUDED.custom_fields, + updated_at = NOW() + RETURNING id, wa_number, contact_number, name, profile_name, tags, custom_fields`, + [record.wa_number, shared.contactNumber, shared.name || shared.contactNumber, JSON.stringify(tags), JSON.stringify(customFields)] + ); + return rows[0] || null; +} + +function isIa360OwnerNumber(phone) { + return normalizePhone(phone) === IA360_OWNER_NUMBER; +} + +async function recordBlockedOwnerNumberVcard({ record, shared }) { + const blockedAt = new Date().toISOString(); + console.warn('[ia360-vcard] blocked owner-number vCard source=%s message=%s', record?.contact_number || '-', record?.message_id || '-'); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: IA360_OWNER_NUMBER, + customFields: { + ia360_owner_number_vcard_blocked_at: blockedAt, + ia360_owner_number_vcard_blocked_source_message_id: record.message_id || '', + ia360_owner_number_vcard_blocked_name: shared?.name || '', + ia360_owner_number_vcard_blocked_reason: 'shared_contact_phone_matches_owner', + }, + }).catch(e => console.error('[ia360-vcard] owner-number block persist:', e.message)); +} + +async function syncIa360Deal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `IA360 · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + // G-G: hot lead created directly at "Requiere Alek" → handoff to EspoCRM (priority high so a human Task is created). + if (targetStage.name === 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: pidió/marcó prioridad alta (entró a "Requiere Alek") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (new):', e.message)); + } + return rows[0]; + } + + const existing = existingRows[0]; + const forceMoveStages = ['Agenda en proceso', 'Reunión agendada', 'Requiere Alek', 'Ganado', 'Perdido / no fit', 'Nutrición']; + const shouldMove = forceMoveStages.includes(targetStage.name) || Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + // G-G: deal just ENTERED "Requiere Alek" (was at a different stage) → handoff to EspoCRM, priority high (creates human Task). + // Transition-detection = idempotent (only fires when crossing into the stage, not on every re-touch while already there). + // No-dup-with-booking: the n8n handoff upserts Contact/Opportunity/Task by name, so a later meeting_confirmed updates the SAME records. + if (shouldMove && targetStage.name === 'Requiere Alek' && existing.current_stage_name !== 'Requiere Alek') { + emitIa360N8nHandoff({ + record, + eventType: 'requires_alek', + targetStage: 'Requiere Alek', + priority: 'high', + summary: `Lead caliente: entró a "Requiere Alek" (desde "${existing.current_stage_name}") sin reunión agendada aún. Crear tarea humana y preparar contacto. Última respuesta: ${record.message_body || ''}.`, + }).catch(e => console.error('[ia360-n8n] requires_alek handoff (move):', e.message)); + } + return { id: existing.id, moved: shouldMove }; +} + +// ============================================================================ +// Pipeline 5 — "WhatsApp Revenue OS": flujo de apertura por dolor (3 pasos). +// Diseño fuente: "Plan diversificacion pipelines WA … Revenue OS" §2. +// PASO 1 (template ia360_os_revenue_apertura, fuera de ventana 24h) → quick +// replies [Sí, cuéntame] / [Ahora no]. +// PASO 2 (texto libre dentro de ventana) → 1 pregunta de diagnóstico; captura +// señal en custom_fields (ia360_revenue_canal/dolor/volumen). +// PASO 3 (texto libre) → propuesta + bifurcación [Ver cómo se vería] / +// [Hablar con Alek]. +// Estado en custom_fields.ia360_revenue_state: apertura_sent → calificacion → +// propuesta → demo|handoff|nutricion. Gatea cada paso para no secuestrar el +// flujo genérico (agente IA / agenda). GUARDRAIL: NO empuja agenda en pasos +// 1-2; la agenda es destino del paso 3 SOLO si el contacto lo pide. +// ============================================================================ +const REVENUE_OS_PIPELINE_NAME = 'WhatsApp Revenue OS'; +const REVENUE_OS_APERTURA_TEMPLATE_ID = 42; // ia360_os_revenue_apertura (APPROVED, es_MX, {{1}}=nombre) + +const REVENUE_OS_COPY = { + paso2: 'Va. Para no suponer: hoy, cuando entra un prospecto por WhatsApp, ¿cómo le siguen el rastro? (ej. lo anotan aparte, se confían a la memoria, un Excel, o de plano se les pierde alguno). Cuéntame en una línea cómo es hoy.', + paso3: 'Eso es justo lo que se nos escapa dinero sin darnos cuenta. Lo que hacemos en TransformIA es montar tu "Revenue OS" sobre el mismo WhatsApp: cada lead entra, se etiqueta solo, sube por etapas (de "nuevo" a "ganado") y tú ves el pipeline completo sin perseguir a nadie. ¿Cómo le seguimos?', + ahoraNo: 'Va, sin problema. Te dejo el espacio y no te lleno el WhatsApp. Si más adelante quieres ordenar tus ventas por aquí, me escribes y lo retomamos. Saludos.', + demo: 'Va, te lo aterrizo. Tu "Revenue OS" se vería así por aquí: (1) cada prospecto que escribe queda registrado y etiquetado solo; (2) avanza por etapas — nuevo, en conversación, propuesta, ganado — sin que tú lo muevas a mano; (3) ves el pipeline completo y quién se está enfriando, todo dentro de WhatsApp. Te preparo un readout con tu caso y, si quieres, lo vemos en vivo con Alek.', +}; + +// Movimiento de deal dedicado a Pipeline 5 (NO toca syncIa360Deal, que es del +// pipeline de agenda vivo). Create-or-move: si no hay deal, lo crea en el stage +// destino; si existe, avanza por posición (igual criterio que syncIa360Deal). +async function syncRevenueOsDeal({ record, targetStageName, titleSuffix = '', notes = '' }) { + if (!record || !record.wa_number || !record.contact_number || !targetStageName) return null; + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = $1 LIMIT 1`, + [REVENUE_OS_PIPELINE_NAME] + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + + const { rows: stageRows } = await pool.query( + `SELECT id, name, position, stage_type + FROM coexistence.pipeline_stages + WHERE pipeline_id = $1 AND name = $2 + LIMIT 1`, + [pipelineId, targetStageName] + ); + const targetStage = stageRows[0]; + if (!targetStage) return null; + + const { rows: userRows } = await pool.query( + `SELECT id FROM coexistence.forgecrm_users WHERE role='admin' ORDER BY id LIMIT 1` + ); + const createdBy = userRows[0]?.id || null; + + const { rows: contactRows } = await pool.query( + `SELECT COALESCE(name, profile_name, $3) AS name + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number, record.contact_number] + ); + const contactName = contactRows[0]?.name || record.contact_number; + const title = `Revenue OS · ${contactName}${titleSuffix ? ' · ' + titleSuffix : ''}`; + const nextNote = `[${new Date().toISOString()}] ${notes || `Stage → ${targetStageName}; input=${record.message_body || ''}`}`; + + const { rows: existingRows } = await pool.query( + `SELECT d.*, s.position AS current_stage_position, s.name AS current_stage_name + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id=d.stage_id + WHERE d.pipeline_id=$1 AND d.contact_wa_number=$2 AND d.contact_number=$3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + + if (existingRows.length === 0) { + const { rows: posRows } = await pool.query( + `SELECT COALESCE(MAX(position),-1)+1 AS pos FROM coexistence.deals WHERE stage_id=$1`, + [targetStage.id] + ); + const status = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const { rows } = await pool.query( + `INSERT INTO coexistence.deals + (pipeline_id, stage_id, title, value, currency, status, assigned_user_id, + contact_wa_number, contact_number, contact_name, notes, position, created_by, + won_at, lost_at) + VALUES ($1,$2,$3,0,'MXN',$4,$5,$6,$7,$8,$9,$10,$11, + ${status === 'won' ? 'NOW()' : 'NULL'}, ${status === 'lost' ? 'NOW()' : 'NULL'}) + RETURNING id`, + [pipelineId, targetStage.id, title, status, createdBy, record.wa_number, record.contact_number, contactName, nextNote, posRows[0].pos, createdBy] + ); + return { id: rows[0].id, created: true, stage: targetStage.name }; + } + + const existing = existingRows[0]; + const shouldMove = Number(targetStage.position) >= Number(existing.current_stage_position); + const finalStageId = shouldMove ? targetStage.id : existing.stage_id; + const finalStatus = targetStage.stage_type === 'won' ? 'won' : targetStage.stage_type === 'lost' ? 'lost' : 'open'; + const finalNotes = `${existing.notes || ''}${existing.notes ? '\n' : ''}${nextNote}`; + await pool.query( + `UPDATE coexistence.deals + SET stage_id = $1, + status = $2, + title = $3, + contact_name = $4, + notes = $5, + updated_at = NOW(), + won_at = CASE WHEN $2='won' THEN COALESCE(won_at, NOW()) ELSE NULL END, + lost_at = CASE WHEN $2='lost' THEN COALESCE(lost_at, NOW()) ELSE NULL END + WHERE id = $6`, + [finalStageId, shouldMove ? finalStatus : existing.status, title, contactName, finalNotes, existing.id] + ); + return { id: existing.id, moved: shouldMove, stage: shouldMove ? targetStage.name : existing.current_stage_name }; +} + +// PASO 1 — dispara la apertura: siembra deal P5 en "Leads desorganizados", marca +// el estado y envía el template aprobado (con sus 2 quick replies). Egress por el +// chokepoint único (enqueueIa360Template → sendQueue). El record es sintético +// (apertura = outbound-first, no hay inbound): message_id único para el dedup. +async function startRevenueOsOpener({ waNumber, contactNumber, name = '' }) { + const wa = normalizePhone(waNumber); + const cn = normalizePhone(contactNumber); + if (!wa || !cn) return { ok: false, error: 'wa_number_and_contact_number_required' }; + + // Upsert contacto + estado (mergeContactIa360State no setea name; lo hacemos aparte + // solo si vino un nombre y el contacto aún no tiene uno). + await mergeContactIa360State({ + waNumber: wa, + contactNumber: cn, + tags: ['pipeline:revenue-os', 'staged'], + customFields: { ia360_revenue_state: 'apertura_sent', ia360_revenue_started_at: new Date().toISOString() }, + }); + if (name) { + await pool.query( + `UPDATE coexistence.contacts SET name = COALESCE(name, $3), updated_at = NOW() + WHERE wa_number=$1 AND contact_number=$2`, + [wa, cn, name] + ).catch(e => console.error('[revenue-os] set name:', e.message)); + } + + const record = { + wa_number: wa, + contact_number: cn, + contact_name: name || cn, + message_id: `revenue_opener:${cn}:${Date.now()}`, + message_type: 'revenue_opener', + message_body: '', + }; + + await syncRevenueOsDeal({ + record, + targetStageName: 'Leads desorganizados', + titleSuffix: 'Apertura', + notes: 'PASO 1: apertura Revenue OS enviada (template ia360_os_revenue_apertura).', + }).catch(e => console.error('[revenue-os] seed deal:', e.message)); + + const sent = await enqueueIa360Template({ + record, + label: 'ia360_os_revenue_apertura', + templateName: 'ia360_os_revenue_apertura', + templateId: REVENUE_OS_APERTURA_TEMPLATE_ID, + }); + return { ok: !!sent.ok, status: sent.status, error: sent.error || null, handlerFor: `${record.message_id}:ia360_os_revenue_apertura` }; +} + +// Heurística ligera para extraer señal del texto de calificación (PASO 2). Se +// guarda el texto crudo siempre; canal/volumen solo si el contacto los dejó ver. +function extractRevenueSignal(text) { + const t = String(text || '').toLowerCase(); + let canal = null; + if (/excel|hoja|spreadsheet|sheet/.test(t)) canal = 'excel'; + else if (/crm|pipedrive|hubspot|salesforce|espocrm|zoho/.test(t)) canal = 'crm'; + else if (/memoria|cabeza|me acuerdo|de memoria/.test(t)) canal = 'memoria'; + else if (/anot|libreta|cuaderno|papel|post.?it|nota/.test(t)) canal = 'notas'; + else if (/whats|wa\b|chat/.test(t)) canal = 'whatsapp'; + const volMatch = t.match(/(\d{1,5})\s*(leads?|prospect|client|mensaj|chats?|al d[ií]a|por d[ií]a|a la semana|mensual|al mes)/); + const volumen = volMatch ? volMatch[0] : null; + return { canal, volumen }; +} + +// PASO 1 (respuesta) + PASO 3 (bifurcación) — botones. Gateado por estado para no +// secuestrar quick-replies genéricos. Devuelve true si lo manejó (corta el embudo). +async function handleRevenueOsButton({ record, replyId }) { + if (!record || !replyId) return false; + const id = String(replyId || '').trim().toLowerCase(); + const isOpenerYes = id === 'sí, cuéntame' || id === 'si, cuéntame' || id === 'sí, cuentame' || id === 'si, cuentame'; + const isOpenerNo = id === 'ahora no'; + const isDemo = id === 'revenue_ver_demo'; + const isHandoff = id === 'revenue_hablar_alek'; + if (!isOpenerYes && !isOpenerNo && !isDemo && !isHandoff) return false; + + const contact = await loadIa360ContactContext(record).catch(() => null); + const state = contact?.custom_fields?.ia360_revenue_state || ''; + + // PASO 1 — respuesta a la apertura (solo si seguimos en apertura_sent). + if (isOpenerYes || isOpenerNo) { + if (state !== 'apertura_sent') return false; // no es de este flujo / ya avanzó + if (isOpenerNo) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: { ia360_revenue_state: 'nutricion', ultimo_cta_enviado: 'ia360_os_revenue_ahora_no' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_ahora_no', body: REVENUE_OS_COPY.ahoraNo }); + return true; + } + // "Sí, cuéntame" → abre ventana 24h → PASO 2 (pregunta de calificación, texto libre). + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-interesado'], + customFields: { ia360_revenue_state: 'calificacion', ultimo_cta_enviado: 'ia360_os_revenue_paso2' }, + }); + await enqueueIa360Text({ record, label: 'ia360_os_revenue_paso2', body: REVENUE_OS_COPY.paso2 }); + return true; + } + + // PASO 3 — bifurcación (solo si ya enviamos la propuesta). + if (isDemo || isHandoff) { + if (state !== 'propuesta') return false; + if (isDemo) { + await enqueueIa360Text({ record, label: 'ia360_os_revenue_demo', body: REVENUE_OS_COPY.demo }); + await syncRevenueOsDeal({ + record, + targetStageName: 'Diseño propuesto', + titleSuffix: 'Diseño propuesto', + notes: 'PASO 3: "Ver cómo se vería" → readout/mini-demo; deal a Diseño propuesto.', + }).catch(e => console.error('[revenue-os] move to Diseño propuesto:', e.message)); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-diseno-propuesto'], + customFields: { ia360_revenue_state: 'demo', ultimo_cta_enviado: 'ia360_os_revenue_demo' }, + }); + return true; + } + // "Hablar con Alek" → handoff al flujo de agenda EXISTENTE respetando la compuerta + // de confirmación: NO empujar offer_slots; preguntamos primero (gate_slots_yes/no), + // que ya maneja el router más abajo y consulta disponibilidad REAL. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_gate_agenda', + messageBody: 'IA360: confirmar horarios', + interactive: { + type: 'button', + body: { text: 'Perfecto, te paso con Alek. ¿Quieres que te comparta horarios para una llamada con él?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, + { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } }, + ], + }, + }, + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-handoff-agenda'], + customFields: { ia360_revenue_state: 'handoff', ultimo_cta_enviado: 'ia360_os_revenue_handoff' }, + }); + return true; + } + return false; +} + +// PASO 2 — captura de calificación (texto libre dentro de ventana). Va ANTES del +// agente genérico en el dispatch y devuelve true para CORTAR el embudo (evita que +// el agente responda el mismo texto y empuje agenda → guardrail). Solo actúa si el +// contacto está en estado 'calificacion'. +async function handleRevenueOsFreeText(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + const body = String(record.message_body || '').trim(); + if (!body) return false; + const contact = await loadIa360ContactContext(record).catch(() => null); + if (!contact || contact.custom_fields?.ia360_revenue_state !== 'calificacion') return false; + + const { canal, volumen } = extractRevenueSignal(body); + const cf = { + ia360_revenue_state: 'propuesta', + ia360_revenue_dolor: body, + ia360_revenue_calificacion_raw: body, + ultimo_cta_enviado: 'ia360_os_revenue_paso3', + }; + if (canal) cf.ia360_revenue_canal = canal; + if (volumen) cf.ia360_revenue_volumen = volumen; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['revenue-os-calificado'], + customFields: cf, + }); + + // PASO 3 — propuesta + bifurcación (quick replies). Titles ≤ 20 chars. + await enqueueIa360Interactive({ + record, + label: 'ia360_os_revenue_paso3', + messageBody: 'IA360: propuesta Revenue OS', + interactive: { + type: 'button', + body: { text: REVENUE_OS_COPY.paso3 }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'revenue_ver_demo', title: 'Ver cómo se vería' } }, + { type: 'reply', reply: { id: 'revenue_hablar_alek', title: 'Hablar con Alek' } }, + ], + }, + }, + }); + return true; + } catch (err) { + console.error('[revenue-os] free-text handler error (no route):', err.message); + return false; + } +} + +async function resolveIa360Outbound(record, dedupSuffix = '') { + // dedupSuffix permite mandar UN segundo mensaje al mismo contacto para el mismo inbound + // sin que el dedup (por ia360_handler_for) lo descarte. Default '' = comportamiento idéntico. + const handlerFor = dedupSuffix ? record.message_id + dedupSuffix : record.message_id; + const { rows } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE direction='outgoing' + AND contact_number=$1 + AND template_meta->>'ia360_handler_for'=$2 + LIMIT 1`, + [record.contact_number, handlerFor] + ); + if (rows.length > 0) return { duplicate: true }; + + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { + console.error('[ia360-lite] account resolution failed:', error || 'unknown'); + return { error: error || 'unknown' }; + } + return { account }; +} + +async function enqueueIa360Interactive({ record, label, messageBody, interactive, dedupSuffix = '' }) { + const resolved = await resolveIa360Outbound(record, dedupSuffix); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + // Audit visibility: persist the full interactive body so the owner can see the + // exact selector text + options the client received (not just the short label). + let auditBody = messageBody; + try { + const bodyText = interactive && interactive.body && interactive.body.text ? String(interactive.body.text) : ''; + const act = (interactive && interactive.action) || {}; + let opts = []; + if (Array.isArray(act.buttons)) { + opts = act.buttons.map(b => (b && b.reply && b.reply.title) ? b.reply.title : '').filter(Boolean); + } else if (Array.isArray(act.sections)) { + opts = act.sections.flatMap(s => Array.isArray(s.rows) ? s.rows.map(r => (r && r.title) ? r.title : '') : []).filter(Boolean); + } + const parts = []; + if (bodyText) parts.push(bodyText); + if (opts.length) parts.push('Opciones: ' + opts.join(' | ')); + if (parts.length) auditBody = messageBody + ' — ' + parts.join(' — '); + } catch (e) { /* keep label-only body on any parsing issue */ } + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'interactive', + messageBody: auditBody, + templateMeta: { + ux: 'ia360_lite', + label, + ia360_handler_for: dedupSuffix ? record.message_id + dedupSuffix : record.message_id, + source: 'webhook_interactive_reply', + }, + rawPayloadExtra: interactive, + }); + await enqueueSend({ + kind: 'interactive', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { interactive }, + }); + return true; +} + +// FlowWire: build a WhatsApp Flow (type:'flow') interactive and enqueue it through the +// existing IA360 interactive path. metaSend.sendInteractive forwards `interactive` verbatim, +// so a flow object is sent as-is. Returns the bool from enqueueIa360Interactive +// (true once enqueued; downstream Meta rejection is NOT observable here). +async function enqueueIa360FlowMessage({ record, flowId, screen, cta, bodyText, mediaUrl, flowToken, label, footer = 'IA360' }) { + const interactive = { + type: 'flow', + header: { type: 'image', image: { link: mediaUrl } }, + body: { text: bodyText }, + footer: { text: footer }, + action: { + name: 'flow', + parameters: { + flow_message_version: '3', + flow_token: flowToken, + flow_id: flowId, + flow_cta: cta, + flow_action: 'navigate', + flow_action_payload: { screen }, + }, + }, + }; + return enqueueIa360Interactive({ + record, + label: label || `ia360_flow_${flowToken}`, + messageBody: `IA360 Flow: ${cta}`, + interactive, + }); +} + +// W4 — "Enviar contexto" es stage-aware (DESAMBIGUACION del brief): si el contacto YA tiene +// slot agendado (ia360_bookings no vacio) abre el Flow pre_call (contexto para la llamada); +// si NO hay slot abre el Flow diagnostico ligero. El stage manda. Devuelve el bool de envio +// (true si se encolo). loadIa360Bookings/enqueueIa360FlowMessage estan hoisted (function decls). +async function dispatchContextFlow(record) { + const bookings = await loadIa360Bookings(record.contact_number); + const hasSlot = Array.isArray(bookings) && bookings.length > 0; + console.log('[ia360-flowwire] send_context contact=%s hasSlot=%s -> %s', record.contact_number, hasSlot, hasSlot ? 'pre_call' : 'diagnostico'); + if (hasSlot) { + return enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_send_context_precall', + }); + } + return enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: 'ia360_send_context_diag', + }); +} + +async function enqueueIa360Text({ record, label, body }) { + const resolved = await resolveIa360Outbound(record); + if (resolved.duplicate || resolved.error) return false; + const { account } = resolved; + + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'text', + messageBody: body, + templateMeta: { + ux: 'ia360_100m', + label, + ia360_handler_for: record.message_id, + source: 'webhook_terminal_handoff', + }, + }); + await enqueueSend({ + kind: 'text', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { body, previewUrl: false }, + }); + return true; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function parseTemplateSamples(samples) { + if (!samples) return {}; + if (typeof samples === 'object') return samples; + try { return JSON.parse(samples); } catch { return {}; } +} + +function templateBodyIndexes(body) { + const out = new Set(); + for (const m of String(body || '').matchAll(/\{\{\s*(\d+)\s*\}\}/g)) out.add(m[1]); + return Array.from(out).sort((a, b) => Number(a) - Number(b)); +} + +function firstNameForTemplate(record) { + const raw = String(record.contact_name || record.profile_name || '').trim(); + const cleaned = raw.replace(/\s+WhatsApp IA360$/i, '').trim(); + return cleaned.split(/\s+/).filter(Boolean)[0] || 'Alek'; +} + +async function resolveTemplateHeaderMediaId(tpl, account) { + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + if (!['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerType)) return null; + if (!tpl.header_media_library_id) { + throw new Error(`template ${tpl.name} requires ${headerType} header media but has no header_media_library_id`); + } + const { rows: mRows } = await pool.query( + `SELECT * FROM coexistence.media_library WHERE id = $1 AND deleted_at IS NULL`, + [tpl.header_media_library_id] + ); + if (!mRows.length) throw new Error(`media library id ${tpl.header_media_library_id} not found`); + const { rows: sRows } = await pool.query( + `SELECT * FROM coexistence.media_meta_sync WHERE media_id = $1 AND account_id = $2`, + [tpl.header_media_library_id, account.id] + ); + let sync = sRows[0]; + const needsSync = !sync || sync.status !== 'synced' || !sync.meta_media_id || (sync.expires_at && new Date(sync.expires_at) <= new Date()); + if (needsSync) { + const { syncMediaToAccount } = require('./mediaLibrary'); + const synced = await syncMediaToAccount(tpl.header_media_library_id, account.id); + sync = { meta_media_id: synced.metaMediaId, expires_at: synced.expiresAt, status: synced.status }; + } + if (!sync?.meta_media_id) throw new Error(`media library id ${tpl.header_media_library_id} has no Meta media id`); + return sync.meta_media_id; +} + +const { validateTemplateSend } = require('../integrations/templateValidator'); + +// Render a template body to plain text using the same param logic as +// buildIa360TemplateComponents ({{1}} = contact first name, other {{n}} = sample). +function renderIa360TemplateBody(tpl, record) { + const samples = parseTemplateSamples(tpl.samples); + return String(tpl.body || '').replace(/\{\{\s*(\d+)\s*\}\}/g, (_, k) => + k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' ')); +} + +async function buildIa360TemplateComponents(tpl, account, record) { + const components = []; + const headerType = String(tpl.header_type || 'NONE').toUpperCase(); + const headerMediaId = await resolveTemplateHeaderMediaId(tpl, account); + if (headerMediaId) { + const key = headerType.toLowerCase(); + components.push({ type: 'header', parameters: [{ type: key, [key]: { id: headerMediaId } }] }); + } + + const samples = parseTemplateSamples(tpl.samples); + const indexes = templateBodyIndexes(tpl.body); + if (indexes.length) { + components.push({ + type: 'body', + parameters: indexes.map(k => ({ + type: 'text', + text: k === '1' ? firstNameForTemplate(record) : String(samples[k] || ' '), + })), + }); + } + return components; +} + +async function waitForIa360OutboundStatus(handlerFor, timeoutMs = 9000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + await sleep(600); + const { rows } = await pool.query( + `SELECT status, error_message, message_id + FROM coexistence.chat_history + WHERE direction='outgoing' + AND template_meta->>'ia360_handler_for'=$1 + ORDER BY id DESC + LIMIT 1`, + [handlerFor] + ); + last = rows[0] || last; + if (last && ['sent', 'failed'].includes(String(last.status || '').toLowerCase())) return last; + } + return last || { status: 'unknown' }; +} + +async function enqueueIa360Template({ record, label, templateName, templateId = null }) { + const resolved = await resolveIa360Outbound(record, `:${label}`); + if (resolved.duplicate || resolved.error) return { ok: false, status: resolved.duplicate ? 'duplicate' : 'error', error: resolved.error || null }; + const { account } = resolved; + let tpl = null; + if (templateId) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE id=$1 AND status='APPROVED' + LIMIT 1`, + [templateId] + ); + tpl = rows[0] || null; + } + if (!tpl && templateName) { + const { rows } = await pool.query( + `SELECT id, name, language, body, status, header_type, header_media_library_id, samples + FROM coexistence.message_templates + WHERE name=$1 AND status='APPROVED' + ORDER BY updated_at DESC + LIMIT 1`, + [templateName] + ); + tpl = rows[0] || null; + } + if (!tpl) { + console.error('[ia360-owner-pipe] approved template not found:', templateName || templateId); + return { ok: false, status: 'template_not_found', error: String(templateName || templateId || '') }; + } + let components; + try { + components = await buildIa360TemplateComponents(tpl, account, record); + } catch (err) { + console.error('[ia360-owner-pipe] template components error:', err.message); + return { ok: false, status: 'template_components_error', error: err.message }; + } + + // Pre-Meta validation against the registered template spec. If the + // outgoing component shape would be rejected by Meta (#132000/#132012), + // do NOT send a broken template. Fall back to free text (rendered body): + // inside the 24h service window Meta delivers it; outside it Meta rejects + // free text and the row is marked failed (logged). That realizes + // "ventana abierta -> texto libre; cerrada -> no enviar y avisar". + try { + const v = await validateTemplateSend(account, tpl.name, tpl.language || 'es_MX', components); + if (!v.valid) { + console.error(`[ia360-owner-pipe] template "${tpl.name}" invalid vs Meta (${v.source}): ${v.errors.join('; ')} -> free-text fallback`); + const sent = await enqueueIa360Text({ record, label: `${label}_textfallback`, body: renderIa360TemplateBody(tpl, record) }); + return { ok: !!sent, status: sent ? 'text_fallback' : 'error', error: sent ? null : 'template invalid and text fallback failed' }; + } + } catch (err) { + console.error('[ia360-owner-pipe] template validation error:', err.message); + } + const handlerFor = `${record.message_id}:${label}`; + const localId = await insertPendingRow({ + account, + toNumber: record.contact_number, + messageType: 'template', + messageBody: tpl.body || tpl.name, + templateMeta: { + ux: 'ia360_owner_pipeline', + label, + ia360_handler_for: handlerFor, + source: 'webhook_owner_pipe', + template_name: tpl.name, + template_id: tpl.id, + header_type: tpl.header_type || 'NONE', + header_media_library_id: tpl.header_media_library_id || null, + }, + }); + await enqueueSend({ + kind: 'template', + accountId: account.id, + to: record.contact_number, + localMessageId: localId, + payload: { name: tpl.name, languageCode: tpl.language || 'es_MX', components }, + }); + const status = await waitForIa360OutboundStatus(handlerFor); + return { + ok: String(status?.status || '').toLowerCase() === 'sent', + status: status?.status || 'unknown', + error: status?.error_message || null, + }; +} + +async function emitIa360N8nHandoff({ record, eventType, targetStage, summary, priority = 'normal' }) { + const url = process.env.N8N_IA360_HANDOFF_WEBHOOK_URL; + if (!url) return false; + try { + const payload = { + source: 'forgechat-ia360-webhook', + eventType, + priority, + targetStage, + summary, + occurredAt: new Date().toISOString(), + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + trigger: { + messageId: record.message_id, + messageType: record.message_type, + messageBody: record.message_body, + timestamp: record.timestamp, + }, + recommendedActions: [ + 'upsert_espocrm_contact', + 'create_human_task', + 'prepare_call_context', + 'optionally_create_zoom_or_calendar_event', + ], + }; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error('[ia360-n8n] handoff failed:', res.status, await res.text().catch(() => '')); + return false; + } + return true; + } catch (err) { + console.error('[ia360-n8n] handoff error:', err.message); + return false; + } +} + +async function requestIa360Availability({ record, day }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + day, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] availability failed:', res.status, text); + return null; + } + return JSON.parse(text); + } catch (err) { + console.error('[ia360-calendar] availability error:', err.message); + return null; + } +} + +function parseIa360SlotId(replyId) { + const m = String(replyId || '').match(/^slot_(\d{8})t(\d{6})z$/i); + if (!m) return null; + const [, ymd, hms] = m; + const start = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}T${hms.slice(0,2)}:${hms.slice(2,4)}:${hms.slice(4,6)}.000Z`; + const end = new Date(new Date(start).getTime() + 60 * 60 * 1000).toISOString(); + return { start, end }; +} + +async function bookIa360Slot({ record, start, end }) { + const url = process.env.N8N_IA360_BOOK_WEBHOOK_URL; + if (!url) return null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-webhook', + start, + end, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || 'WhatsApp IA360', + }, + }), + }); + const text = await res.text(); + if (!res.ok) { + console.error('[ia360-calendar] book failed:', res.status, text); + return { ok: false, reason: 'book_failed' }; + } + return JSON.parse(text || '{}'); + } catch (err) { + console.error('[ia360-calendar] book error:', err.message); + return { ok: false, reason: 'book_error' }; + } +} + +function getInteractiveReplyId(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const msg = payload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; + const interactive = msg?.interactive; + if (interactive) { + if (interactive.button_reply?.id) return String(interactive.button_reply.id).trim().toLowerCase(); + if (interactive.list_reply?.id) return String(interactive.list_reply.id).trim().toLowerCase(); + } + if (msg?.button?.payload) return String(msg.button.payload).trim().toLowerCase(); + if (msg?.button?.text) return String(msg.button.text).trim().toLowerCase(); + } catch (_) { + // ignore malformed/non-JSON payloads; fallback to visible title + } + return ''; +} + + +// ── IA360 free-text AI agent (G-B fix) ─────────────────────────────────────── +// When a prospect with an ACTIVE, NON-TERMINAL IA360 deal sends FREE TEXT (not a +// button), the button state machine no-ops. Instead of silence, hand off to the +// n8n AI agent which classifies intent + extracts a date, then ForgeChat acts: +// - offer_slots: query REAL Calendar freebusy for the agent's date, send slots +// - optout: send reply, move deal to "Perdido / no fit" +// - else: send the agent's coherent reply (and advance pain if asked) +// Fire-and-forget from the inbound loop so it never blocks the Meta 200 ack. +async function getActiveNonTerminalIa360Deal(record) { + const { rows: pipeRows } = await pool.query( + `SELECT id FROM coexistence.pipelines WHERE name = 'IA360 WhatsApp Revenue Pipeline' LIMIT 1` + ); + const pipelineId = pipeRows[0]?.id; + if (!pipelineId) return null; + const { rows } = await pool.query( + `SELECT d.id, s.name AS stage_name, s.stage_type + FROM coexistence.deals d + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.pipeline_id = $1 AND d.contact_wa_number = $2 AND d.contact_number = $3 + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [pipelineId, record.wa_number, record.contact_number] + ); + const deal = rows[0]; + if (!deal) { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact)) { + return { + id: null, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + const txt = String(record.message_body || '').toLowerCase(); + // Reschedule intent: a prospect with an already-booked meeting who wants to move it + // SHOULD reach the agent (we explicitly invite "escríbeme por aquí" in the confirmation). + const wantsReschedule = /reagend|reprogram|mover|mu[eé]v|cambi|otro d[ií]a|otra hora|otro horario|otra fecha|posponer|recorr|adelantar|cancel|anul|ya no (podr|voy|asist)/.test(txt); + // MULTI-CITA: un prospecto YA agendado puede querer una SEGUNDA reunión. Ese mensaje + // ("y otra para el miércoles", "necesito otra reunión") NO es reagendar ni pasivo, así + // que también debe llegar al agente. Sin esto el gate lo silenciaba (return null). + const wantsBooking = /reuni[oó]n|cita|agend|coordin|horario|disponib|otra (para|cita|reuni)|una m[aá]s|otra m[aá]s|necesito una|quiero (una|agendar)|me gustar[ií]a (una|agendar)/i.test(txt); + // LIST intent ("¿cuáles tengo?", "qué citas tengo", "mis reuniones"): un prospecto YA + // agendado que quiere CONSULTAR sus citas debe llegar al agente (list_bookings). Sin + // esto el gate lo silenciaba estando en "Reunión agendada". + const wantsList = /(cu[aá]l|cu[aá]nt|qu[eé]).{0,14}(tengo|hay|agend|cita|reuni)|mis (citas|reuni)|ver (mis )?(citas|reuni)|tengo .{0,10}(agend|cita|reuni)/i.test(txt); + // Always-terminal = won/lost → never auto-prospect. Explicit client-beta context + // is the exception: it can learn/respond with delight guardrails, not sell. + if (deal.stage_type === 'won' || deal.stage_type === 'lost' || deal.stage_name === 'Ganado' || deal.stage_name === 'Perdido / no fit') { + const contact = await loadIa360ContactContext(record).catch(() => null); + if (isIa360ClienteActivoBetaContact(contact) && (deal.stage_type === 'won' || deal.stage_name === 'Ganado')) { + return { + ...deal, + stage_name: 'Cliente activo beta supervisado', + stage_type: 'active_client_beta', + memory_mode: 'cliente_activo_beta_supervisado', + contact_context: contact, + }; + } + return null; + } + // Gap#1: un contacto YA agendado que manda texto SUSTANTIVO (una duda, una pregunta) + // debe recibir respuesta conversacional del agente. Solo silenciamos texto PASIVO + // ("gracias"/"ok"/"nos vemos"/smalltalk) para no re-disparar el agente sin necesidad; + // el resto pasa al agente y cae al reply DEFAULT abajo. + if (deal.stage_name === 'Reunión agendada' && !wantsReschedule && !wantsBooking && !wantsList && isIa360PassiveMessage(record.message_body)) return null; + return deal; +} + +const N8N_IA360_CONTACT_INTEL_WEBHOOK_URL = process.env.N8N_IA360_CONTACT_INTEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-contact-intelligence-agent-draft'; + +function buildIa360AgentPayload({ record, stageName, history, source }) { + return { + source, + channel: 'whatsapp', + dry_run: source === 'forgechat-ia360-contact-intelligence-shadow', + text: record.message_body || '', + stage: stageName, + history, + message_id: record.message_id || null, + wa_number: record.wa_number || null, + contact_number: record.contact_number || null, + contact_name: record.contact_name || record.profile_name || null, + }; +} + +async function shadowIa360ContactIntelligence({ record, stageName, history }) { + const url = N8N_IA360_CONTACT_INTEL_WEBHOOK_URL; + if (!url) return; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-contact-intelligence-shadow', + })), + }); + if (!res.ok) console.error('[ia360-contact-intel] shadow failed:', res.status); + } catch (err) { + console.error('[ia360-contact-intel] shadow error:', err.message); + } +} + +async function callIa360Agent({ record, stageName }) { + // Recent conversation for context (last 8 messages). + const { rows: hist } = await pool.query( + `SELECT direction AS dir, message_body AS body + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 + ORDER BY timestamp DESC LIMIT 8`, + [record.wa_number, record.contact_number] + ); + const history = hist.reverse().map(h => ({ dir: h.dir, body: h.body })); + + // EXPEDIENTE: memoria por contacto (facts+events) para que el agente responda + // con contexto real del negocio del contacto, no en frio. Best-effort. + let agentMemory = null; + try { + const memContact = await loadIa360ContactContext(record); + agentMemory = await lookupIa360MemoryContext({ record, contact: memContact, limit: 8 }); + } catch (memErr) { + console.error('[ia360-agent] memory lookup failed:', memErr.message); + } + + const primaryIa360AgentUrl = process.env.N8N_IA360_AGENT_WEBHOOK_URL; + if (N8N_IA360_CONTACT_INTEL_WEBHOOK_URL && N8N_IA360_CONTACT_INTEL_WEBHOOK_URL !== primaryIa360AgentUrl) { + shadowIa360ContactIntelligence({ record, stageName, history }).catch(() => {}); + } + + const url = primaryIa360AgentUrl; + if (!url) return null; + // n8n agent latency is ~16s in normal conditions; bound the call at 30s so a + // hung n8n fails fast instead of riding undici's ~300s default. Empty/partial + // bodies (n8n returning 200 with no JSON) degrade to null → holding reply. + const ia360AgentController = new AbortController(); + const ia360AgentTimer = setTimeout(() => ia360AgentController.abort(), 30000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...buildIa360AgentPayload({ + record, + stageName, + history, + source: 'forgechat-ia360-webhook', + }), + memory: agentMemory, + }), + signal: ia360AgentController.signal, + }); + if (!res.ok) { console.error('[ia360-agent] failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { + console.error('[ia360-agent] empty body: status=%s len=%s', res.status, text ? text.length : 0); + return null; + } + try { + return JSON.parse(text); + } catch (parseErr) { + console.error('[ia360-agent] bad JSON: status=%s len=%s err=%s', res.status, text.length, parseErr.message); + return null; + } + } catch (err) { + console.error('[ia360-agent] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(ia360AgentTimer); + } +} + +// ── IA360 Human-in-the-loop (owner notify + cancelar conversacional) ───────── +// Owner = Alek. HOY su numero es el MISMO que el prospecto de prueba, asi que la +// rama owner discrimina por PREFIJO de id de boton ('owner_'), NO por numero. +// Reflejo por interaccion a EspoCRM: find-or-update de UN Contact con campos ia360_*. +// Best-effort: nunca bloquea la respuesta WA, nunca lanza, nunca toca ia360_bot_failures. +// Llave estable WA = espo_id cacheado en coexistence.contacts.custom_fields (no toca el phone de EspoCRM). +const N8N_IA360_UPSERT_WEBHOOK_URL = process.env.N8N_IA360_UPSERT_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-espocrm-upsert'; +async function reflectIa360ToEspoCrm({ record, agent, channel = 'whatsapp' }) { + try { + if (!record || !record.wa_number || !record.contact_number) return; + const { rows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const espoId = rows[0] && rows[0].espo_id ? rows[0].espo_id : null; + const name = rows[0] && rows[0].name ? rows[0].name : null; + const payload = { + channel, + identifier: record.contact_number, + espo_id: espoId, + name, + intent: (agent && agent.intent) || null, + action: (agent && agent.action) || null, + extracted: (agent && agent.extracted) || {}, + last_message: buildIa360CrmCompactNote({ record, agent }), + transcript_stored: false, + }; + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { console.error('[ia360-crm] upsert failed:', res.status); return; } + const out = await res.json().catch(() => null); + if (out && out.ok && out.espo_id && out.espo_id !== espoId) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { espo_id: String(out.espo_id) }, + }).catch(() => {}); + } + } catch (err) { + console.error('[ia360-crm] reflect error:', err.message); + } +} + +async function notifyOwnerVcardCaptured({ record, shared }) { + const who = shared.name || shared.contactNumber; + const body = `Alek, recibí un contacto compartido por WhatsApp y ya lo dejé capturado.\n\nNombre: ${who}\nWhatsApp: ${shared.contactNumber}\n\nPrimero clasifica qué tipo de persona/contacto es. En esta etapa NO envío secuencias automáticas desde vCard; solo guardo contexto para elegir después una secuencia lógica.`; + return sendOwnerInteractive({ + record, + label: `owner_vcard_captured_${shared.contactNumber}`, + messageBody: `IA360: vCard ${who}`, + targetContact: shared.contactNumber, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Contacto capturado' }, + body: { text: body }, + footer: { text: 'Captura primero, persona después, envío al final' }, + action: { + button: 'Elegir persona', + sections: [{ + title: 'Clasificación', + rows: [ + { id: `owner_pipe:${shared.contactNumber}:persona_beta`, title: 'Beta / amigo', description: 'Técnico, conocido o prueba' }, + { id: `owner_pipe:${shared.contactNumber}:persona_referido`, title: 'Referido / BNI', description: 'Intro o recomendación' }, + { id: `owner_pipe:${shared.contactNumber}:persona_aliado`, title: 'Aliado / socio', description: 'Canal o proveedor' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cliente`, title: 'Cliente activo', description: 'Engage o deleitar' }, + { id: `owner_pipe:${shared.contactNumber}:persona_sponsor`, title: 'Sponsor ejecutivo', description: 'Buyer o dueño' }, + { id: `owner_pipe:${shared.contactNumber}:persona_comercial`, title: 'Director comercial', description: 'Ventas o pipeline' }, + { id: `owner_pipe:${shared.contactNumber}:persona_cfo`, title: 'CFO / finanzas', description: 'Control, datos o dinero' }, + { id: `owner_pipe:${shared.contactNumber}:persona_tecnico`, title: 'Guardián técnico', description: 'Integración o permisos' }, + { id: `owner_pipe:${shared.contactNumber}:guardar`, title: 'Solo guardar', description: 'Captura sin envío' }, + { id: `owner_pipe:${shared.contactNumber}:excluir`, title: 'No contactar', description: 'Exclusión sin envío' }, + ], + }], + }, + }, + }); +} + +const IA360_PERSONA_SEQUENCE_FLOWS = { + persona_beta: { + personaContext: 'Beta / amigo', + relationshipContext: 'beta_amigo', + flywheelPhase: 'Engage', + riskLevel: 'low', + notes: 'Contacto de confianza o prueba técnica; no tratar como prospecto frío.', + sequences: [ + { + id: 'beta_architectura', + uiTitle: 'Validar arquitectura', + label: 'Validar arquitectura IA360', + goal: 'validar si el flujo persona-first se entiende', + expectedSignal: 'feedback sobre claridad, límites y arquitectura del flujo', + nextAction: 'Alek revisa si la explicación técnica tiene sentido antes de pedir feedback real.', + cta: 'pedir permiso para una pregunta corta de validación', + step2: { + si_pregunta: 'Va la pregunta: si este mensaje te hubiera llegado sin conocer a Alek, ¿se entiende qué es IA360 y qué puedo y no puedo hacer como IA, o hay algo que te haría desconfiar? Dímelo con toda franqueza; para eso es esta prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está construyendo IA360, un sistema que conecta WhatsApp, CRM y memoria de clientes, y me pidió validarlo con gente de su confianza antes de usarlo con clientes reales. No te quiero vender nada: solo necesito tu ojo técnico. ¿Me dejas hacerte una pregunta corta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_architectura:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_architectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_architectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_feedback', + uiTitle: 'Pedir feedback técnico', + label: 'Pedir feedback técnico', + goal: 'obtener crítica concreta', + expectedSignal: 'comentario técnico accionable sobre una parte del sistema', + nextAction: 'Alek edita la pregunta técnica y decide si la manda como prueba controlada.', + cta: 'pedir una crítica concreta del flujo', + step2: { + si_pregunta: 'Gracias. ¿Cómo se siente recibir un mensaje así de una IA: natural, raro o invasivo? Lo que me digas se lo paso a Alek tal cual, sin suavizarlo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek está probando IA360 (su sistema de WhatsApp + CRM con memoria) con contactos de confianza y quiere críticas directas, no cumplidos. ¿Me dejas hacerte una pregunta breve sobre cómo se siente recibir mensajes de una IA como esta?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_feedback:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_beta_feedback:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_feedback:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'beta_memoria', + uiTitle: 'Probar memoria/contexto', + label: 'Probar memoria/contexto', + goal: 'probar si IA360 recuerda contexto útil', + expectedSignal: 'validación de si el contexto guardado ayuda o estorba', + nextAction: 'Alek confirma qué contexto usar en la prueba antes de escribir.', + cta: 'probar memoria con una pregunta controlada', + step2: { + si_a_ver: 'Va. Pregúntame algo que Alek y tú hayan platicado o trabajado antes, y te digo qué tengo registrado. Tú pones la prueba.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Estoy aprendiendo a recordar el contexto de cada persona sin volverme invasiva, y Alek me pidió probarlo contigo porque te tiene confianza. ¿Me dejas hacerte una pregunta corta para poner a prueba mi memoria?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_beta_memoria:si_a_ver', title: 'Sí, a ver' }, + { id: 'seq_beta_memoria:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_beta_memoria:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_referido: { + personaContext: 'Referido / BNI', + relationshipContext: 'referido_bni', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Proteger reputación del canal; pedir contexto y permiso antes de vender.', + sequences: [ + { + id: 'referido_contexto', + uiTitle: 'Pedir contexto intro', + label: 'Pedir contexto de intro', + goal: 'entender de dónde viene la intro', + expectedSignal: 'origen de la introducción, dolor probable o permiso para avanzar', + nextAction: 'Alek completa el contexto del referidor antes de mandar cualquier mensaje.', + cta: 'pedir contexto breve de la introducción', + step2: { + pregunta: '¿Qué te contó la persona que nos presentó sobre lo que hace Alek, y qué te llamó la atención para aceptar la introducción? Con eso evitamos mandarte algo fuera de lugar.', + }, + draft: ({ name, quienIntro }) => `Hola ${name}, soy la IA de Alek. Te escribo porque nos presentó ${quienIntro || '{{quien_intro}}'} y, antes de mandarte cualquier propuesta, Alek quiere entender tu contexto para no escribirte algo fuera de lugar. ¿Cómo prefieres empezar?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_contexto:pregunta', title: 'Hazme una pregunta' }, + { id: 'seq_referido_contexto:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_contexto:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'referido_oneliner', + uiTitle: 'One-liner cuidadoso', + label: 'One-liner cuidadoso', + goal: 'explicar IA360 sin pitch agresivo', + expectedSignal: 'interés inicial sin romper la confianza del canal', + nextAction: 'Alek ajusta el one-liner según quién hizo la introducción.', + cta: 'pedir permiso para explicar IA360 en una línea', + step2: { + si_cuentame: 'Va la versión completa en corto: IA360 conecta WhatsApp, CRM, agenda y memoria de clientes para que el seguimiento no dependa de la memoria de nadie. ¿En tu operación dónde se cae más el seguimiento hoy: mensajes, CRM o agenda?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Nos presentaron hace poco y Alek prefiere darte la versión corta antes que una llamada a ciegas: IA360 evita que el seguimiento se caiga entre WhatsApp, el CRM, la agenda y la gente. ¿Quieres explorar si aplica a tu caso?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_oneliner:si_cuentame', title: 'Sí, cuéntame más' }, + { id: 'seq_referido_oneliner:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_referido_oneliner:ahora_no', title: 'Por ahora no' }, + ], + }, + }, + { + id: 'referido_permiso_agenda', + uiTitle: 'Agendar con permiso', + label: 'Agendar con permiso', + goal: 'pedir permiso antes de agenda', + expectedSignal: 'permiso explícito para explorar una llamada o siguiente paso', + nextAction: 'Alek confirma que la introducción justifica proponer agenda.', + cta: 'pedir permiso para sugerir una llamada', + step2: { + pregunta: 'Claro, pregunta con confianza: qué hace IA360, cómo trabaja Alek o qué implicaría la llamada. Te respondo aquí mismo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Vienes de una introducción y Alek no quiere mandarte una agenda sin contexto. Si ordenar WhatsApp, CRM y seguimiento te suena útil, puedo proponerte una llamada corta con él. ¿Cómo lo ves?`, + metaTemplateName: 'ia360_referido_apertura', + // El template de Meta trae botones de texto ("Sí, cuéntame"); su afirmativo + // mapea a la rama de horarios (el copy pide permiso para proponer llamada). + templateAliasOption: 'horarios', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_referido_permiso_agenda:horarios', title: 'Proponme horarios' }, + { id: 'seq_referido_permiso_agenda:pregunta', title: 'Primero una pregunta' }, + { id: 'seq_referido_permiso_agenda:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_aliado: { + personaContext: 'Aliado / socio', + relationshipContext: 'aliado_socio', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Explorar colaboración, canal o reventa sin exponer datos sensibles.', + sequences: [ + { + id: 'aliado_mapa_colaboracion', + uiTitle: 'Mapa colaboración', + label: 'Mapa de colaboración', + goal: 'detectar cómo pueden colaborar', + expectedSignal: 'tipo de colaboración posible y segmento de clientes compatible', + nextAction: 'Alek define si la conversación es canal, proveedor, implementación o co-venta.', + cta: 'mapear fit de colaboración', + step2: { + si_pregunta: 'Gracias. ¿Qué tipo de clientes atiendes hoy y dónde los ves sufrir más: WhatsApp desordenado, CRM sin seguimiento o procesos repetidos a mano? Con eso mapeamos el fit.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió escribirte porque te ve como posible aliado, no como cliente: quiere explorar si IA360 les sirve a los clientes que tú ya atiendes cuando tienen fricción en WhatsApp, CRM o procesos repetidos. ¿Te hago una pregunta corta para mapear si hay fit?`, + metaTemplateName: 'ia360_aliado_mapa_colaboracion', + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_mapa_colaboracion:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_mapa_colaboracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_mapa_colaboracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_criterios_fit', + uiTitle: 'Criterios de fit', + label: 'Criterios de fit', + goal: 'definir a quién sí conviene presentar', + expectedSignal: 'criterios de cliente ideal o señales para descartar', + nextAction: 'Alek valida criterios de fit antes de pedir intros.', + cta: 'pedir señales de cliente compatible', + step2: { + si_pregunta: 'Va: cuando un cliente tuyo ya necesita ordenar WhatsApp, CRM o seguimiento, ¿qué señales lo delatan primero? Con eso definimos juntos a quién sí presentarle IA360.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek no quiere pedirte intros a ciegas: primero quiere definir contigo qué tipo de empresa sí tiene sentido para IA360. ¿Me dejas preguntarte qué señales ves cuando un cliente ya necesita ordenar su WhatsApp, CRM o seguimiento?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_criterios_fit:si_pregunta', title: 'Sí, pregúntame' }, + { id: 'seq_aliado_criterios_fit:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_criterios_fit:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'aliado_caso_reventa', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar material para explicar IA360 sin exponer datos', + expectedSignal: 'interés en caso seguro para presentar o revender', + nextAction: 'Alek elige el caso NDA-safe correcto antes de compartirlo.', + cta: 'ofrecer caso seguro y resumido', + step2: { + si_comparte: 'Va el caso NDA-safe en corto: una empresa de servicios perdía seguimiento entre WhatsApp y su CRM; con IA360 cada conversación queda registrada, el pipeline se mueve solo y el dueño revisa su semana en un tablero. ¿Le haría sentido a alguno de tus clientes?', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek preparó un caso NDA-safe de IA360 (el problema, la operación antes y el resultado esperado) para que puedas explicárselo a tus clientes sin exponer datos de nadie. ¿Te lo comparto?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_aliado_caso_reventa:si_comparte', title: 'Sí, compártelo' }, + { id: 'seq_aliado_caso_reventa:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_aliado_caso_reventa:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_cliente: { + personaContext: 'Cliente activo', + relationshipContext: 'cliente_activo', + flywheelPhase: 'Deleitar', + riskLevel: 'low', + notes: 'Primero continuidad, adopción o soporte; no abrir venta nueva sin contexto.', + sequences: [ + { + id: 'cliente_readout', + uiTitle: 'Readout de avance', + label: 'Readout de avance', + goal: 'mostrar valor logrado', + expectedSignal: 'avance confirmado, evidencia o siguiente punto pendiente', + nextAction: 'Alek revisa el avance real del proyecto antes de enviar readout.', + cta: 'pedir validación del avance', + step2: { + si_cuento: 'Te leo. Cuéntame el avance, la fricción o el pendiente con el detalle que quieras; se lo dejo a Alek con contexto hoy mismo.', + todo_bien: 'Qué bueno. Le paso a Alek que todo va en orden. Cualquier cosa que surja, me escribes por aquí y se lo pongo enfrente.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Como ya estamos trabajando juntos, Alek me pidió darle seguimiento a tu proyecto sin esperar a la siguiente reunión. ¿Hay algún avance, fricción o pendiente que quieras que le ponga enfrente hoy?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_readout:si_cuento', title: 'Sí, te cuento' }, + { id: 'seq_cliente_readout:todo_bien', title: 'Todo va bien' }, + { id: 'seq_cliente_readout:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_soporte', + uiTitle: 'Soporte rápido', + label: 'Soporte rápido', + goal: 'resolver fricción y aprender', + expectedSignal: 'bloqueo operativo, duda o necesidad de soporte', + nextAction: 'Alek confirma si hay soporte pendiente antes de abrir expansión.', + cta: 'detectar fricción concreta', + step2: { + hay_tema: 'Cuéntame el tema con el detalle que quieras; se lo paso a Alek hoy mismo con prioridad para que no se quede atorado.', + todo_orden: 'Perfecto, me da gusto. Le confirmo a Alek que no hay pendientes de su lado. Aquí sigo si surge algo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de siguientes pasos en tu proyecto, Alek quiere asegurarse de que nada esté atorado de su lado. ¿Hay alguna fricción concreta que quieras que vea primero?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cliente_soporte:hay_tema', title: 'Sí, hay un tema' }, + { id: 'seq_cliente_soporte:todo_orden', title: 'Todo en orden' }, + { id: 'seq_cliente_soporte:alek_directo', title: 'Que me escriba Alek' }, + ], + }, + }, + { + id: 'cliente_expansion', + uiTitle: 'Detectar expansión', + label: 'Detectar expansión', + goal: 'identificar siguiente módulo', + expectedSignal: 'área donde el cliente ya ve oportunidad de continuidad', + nextAction: 'Alek valida que exista adopción o evidencia antes de proponer expansión.', + cta: 'identificar siguiente módulo con permiso', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Te escribo de su parte porque tú y Alek ya tienen un proyecto andando, y Alek quiere ubicar dónde estaría el siguiente paso con más impacto, sin empujarte nada fuera de tiempo. De estas áreas, ¿cuál te quita más tiempo hoy?`, + requiresLiveDeal: true, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cliente_expansion:whatsapp', title: 'WhatsApp y mensajes' }, + { id: 'seq_cliente_expansion:crm', title: 'CRM y clientes' }, + { id: 'seq_cliente_expansion:datos', title: 'Datos y reportes' }, + { id: 'seq_cliente_expansion:agenda', title: 'Agenda y citas' }, + { id: 'seq_cliente_expansion:seguimiento', title: 'Seguimiento de ventas' }, + { id: 'seq_cliente_expansion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_sponsor: { + personaContext: 'Sponsor ejecutivo', + relationshipContext: 'sponsor_ejecutivo', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Traducir IA a tiempo, dinero, riesgo y conversación ejecutiva.', + sequences: [ + { + id: 'sponsor_diagnostico', + uiTitle: 'Diagnóstico ejecutivo', + label: 'Diagnóstico ejecutivo', + goal: 'ubicar cuello que mueve tiempo/dinero', + expectedSignal: 'cuello ejecutivo prioritario y disposición a conversar', + nextAction: 'Alek decide si la pregunta va a operación, ventas, datos o seguimiento.', + cta: 'detectar cuello ejecutivo', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte antes de mandarte una demo genérica: prefiere ubicar primero dónde habría valor real para tu operación. De estas áreas, ¿dónde sientes el cuello de botella que más mueve la aguja?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_sponsor_diagnostico:operacion', title: 'Operación' }, + { id: 'seq_sponsor_diagnostico:ventas', title: 'Ventas' }, + { id: 'seq_sponsor_diagnostico:datos', title: 'Datos y reportes' }, + { id: 'seq_sponsor_diagnostico:seguimiento', title: 'Seguimiento' }, + { id: 'seq_sponsor_diagnostico:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_fuga_valor', + uiTitle: 'Fuga tiempo/dinero', + label: 'Fuga de tiempo/dinero', + goal: 'traducir IA a impacto de negocio', + expectedSignal: 'mención de costo, demora, retrabajo o pérdida de visibilidad', + nextAction: 'Alek prepara una lectura de impacto antes de sugerir solución.', + cta: 'pedir síntoma de fuga de valor', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando IA360 sí aplica, se nota en cuatro fugas: tiempo perdido en tareas manuales, seguimiento que se cae, datos poco confiables y decisiones lentas. ¿Cuál de esas te preocupa más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir fuga', + options: [ + { id: 'seq_sponsor_fuga_valor:tiempo', title: 'Tiempo perdido' }, + { id: 'seq_sponsor_fuga_valor:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_sponsor_fuga_valor:datos', title: 'Datos poco confiables' }, + { id: 'seq_sponsor_fuga_valor:decisiones', title: 'Decisiones lentas' }, + { id: 'seq_sponsor_fuga_valor:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'sponsor_caso_ndasafe', + uiTitle: 'Caso NDA-safe', + label: 'Caso NDA-safe', + goal: 'dar prueba sin exponer clientes', + expectedSignal: 'interés en evidencia ejecutiva sin datos sensibles', + nextAction: 'Alek elige el caso más parecido antes de compartirlo.', + cta: 'ofrecer prueba segura', + step2: { + si_manda: 'Va el caso en corto: una operación que dependía de WhatsApp y Excel perdía seguimiento y visibilidad; con IA360 los mensajes alimentan el CRM, el pipeline se mueve solo y la dirección revisa su semana en un tablero. Si quieres, Alek te aterriza el paralelo con tu operación en una llamada corta.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si prefieres ver evidencia antes de hablar de soluciones, Alek puede compartirte un caso NDA-safe de IA360: el problema, el enfoque y el resultado esperado, sin exponer datos de ningún cliente. ¿Te lo mando?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_sponsor_caso_ndasafe:si_manda', title: 'Sí, mándalo' }, + { id: 'seq_sponsor_caso_ndasafe:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_sponsor_caso_ndasafe:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, + persona_comercial: { + personaContext: 'Director comercial', + relationshipContext: 'director_comercial', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Centrar la conversación en fuga de leads, seguimiento y WhatsApp/CRM.', + sequences: [ + { + id: 'comercial_pipeline', + uiTitle: 'Auditar pipeline', + label: 'Auditar pipeline', + goal: 'detectar fuga de leads', + expectedSignal: 'fuga en generación, seguimiento, cierre o visibilidad', + nextAction: 'Alek confirma si conviene hacer diagnóstico comercial antes de proponer.', + cta: 'ubicar fuga principal del pipeline', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja ayudando a equipos comerciales y casi siempre el problema aparece en uno de tres lugares. En tu equipo, ¿cuál duele más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_pipeline:leads', title: 'Leads que no llegan' }, + { id: 'seq_comercial_pipeline:seguimiento', title: 'Seguimiento que se cae' }, + { id: 'seq_comercial_pipeline:contexto', title: 'WhatsApp sin contexto' }, + { id: 'seq_comercial_pipeline:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_wa_crm', + uiTitle: 'WhatsApp + CRM', + label: 'WhatsApp + CRM', + goal: 'mapear seguimiento y contexto', + expectedSignal: 'dolor entre conversaciones, CRM y seguimiento comercial', + nextAction: 'Alek revisa si el caso pide orden operativo o motor de ventas.', + cta: 'mapear seguimiento WhatsApp/CRM', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Muchas fugas comerciales no vienen del vendedor, sino de WhatsApp y el CRM trabajando sin contexto compartido. En tu operación, ¿qué se pierde más hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_wa_crm:historial', title: 'Historial de clientes' }, + { id: 'seq_comercial_wa_crm:seguimiento', title: 'Seguimiento' }, + { id: 'seq_comercial_wa_crm:prioridad', title: 'Prioridad de leads' }, + { id: 'seq_comercial_wa_crm:datos', title: 'Datos para decidir' }, + { id: 'seq_comercial_wa_crm:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'comercial_motor_prospeccion', + uiTitle: 'Motor prospección', + label: 'Motor de prospección', + goal: 'conectar dolor con oferta concreta', + expectedSignal: 'canal, segmento o proceso que podría convertirse en motor comercial', + nextAction: 'Alek valida segmento y oferta antes de hablar de prospección.', + cta: 'detectar si hay motor comercial repetible', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Para aplicar IA360 a prospección hacen falta tres piezas: un segmento claro, un mensaje repetible y un seguimiento medible. ¿Qué parte de ese motor está más débil en tu equipo hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_comercial_motor_prospeccion:segmento', title: 'Segmento claro' }, + { id: 'seq_comercial_motor_prospeccion:mensaje', title: 'Mensaje repetible' }, + { id: 'seq_comercial_motor_prospeccion:seguimiento', title: 'Seguimiento medible' }, + { id: 'seq_comercial_motor_prospeccion:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_cfo: { + personaContext: 'CFO / finanzas', + relationshipContext: 'cfo_finanzas', + flywheelPhase: 'Attract', + riskLevel: 'medium', + notes: 'Hablar de control, confiabilidad de datos, cartera, comisiones o conciliación.', + sequences: [ + { + id: 'cfo_control', + uiTitle: 'Auditar control', + label: 'Auditar control', + goal: 'detectar pérdida de control operativo', + expectedSignal: 'dolor de control, visibilidad o retrabajo financiero', + nextAction: 'Alek decide si el ángulo financiero es control, cartera o comisiones.', + cta: 'detectar punto de control débil', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek trabaja con equipos de finanzas que terminan operando a mano porque no pueden confiar rápido en sus datos. En tu caso, ¿dónde está el mayor dolor hoy?`, + openerOptions: { + kind: 'list', + button: 'Elegir área', + options: [ + { id: 'seq_cfo_control:cartera', title: 'Cartera' }, + { id: 'seq_cfo_control:comisiones', title: 'Comisiones' }, + { id: 'seq_cfo_control:reportes', title: 'Reportes' }, + { id: 'seq_cfo_control:conciliacion', title: 'Conciliación' }, + { id: 'seq_cfo_control:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'cfo_cartera_datos', + uiTitle: 'Cartera/datos', + label: 'Cartera/datos', + goal: 'ubicar dinero o datos poco visibles', + expectedSignal: 'mención de cartera, cobranza, datos dispersos o visibilidad lenta', + nextAction: 'Alek confirma si hay un flujo de datos que se pueda ordenar sin invadir sistemas.', + cta: 'ubicar datos financieros poco visibles', + step2: { + respondo: 'Te leo. Cuéntame qué información te cuesta más tener confiable y a tiempo (cartera, cobranza, reportes), y se la paso a Alek aterrizada.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Cuando la cartera o los datos viven dispersos, la decisión financiera llega tarde. ¿Qué información te cuesta más tener confiable y a tiempo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_cfo_cartera_datos:respondo', title: 'Te respondo aquí' }, + { id: 'seq_cfo_cartera_datos:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_cfo_cartera_datos:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'cfo_comisiones', + uiTitle: 'Comisiones/reglas', + label: 'Comisiones / conciliación', + goal: 'detectar reglas que generan errores', + expectedSignal: 'regla manual, conciliación lenta o disputa por cálculo', + nextAction: 'Alek valida si el caso se puede convertir en diagnóstico de reglas y datos.', + cta: 'detectar reglas financieras propensas a error', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. En comisiones y conciliación, el problema suele estar en reglas manuales, excepciones y datos que no cuadran. ¿Dónde se te va más tiempo revisando o corrigiendo?`, + openerOptions: { + kind: 'list', + button: 'Elegir', + options: [ + { id: 'seq_cfo_comisiones:reglas', title: 'Reglas manuales' }, + { id: 'seq_cfo_comisiones:excepciones', title: 'Excepciones' }, + { id: 'seq_cfo_comisiones:datos', title: 'Datos que no cuadran' }, + { id: 'seq_cfo_comisiones:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + ], + }, + persona_tecnico: { + personaContext: 'Guardián técnico', + relationshipContext: 'guardian_tecnico', + flywheelPhase: 'Engage', + riskLevel: 'medium', + notes: 'Reducir fricción técnica con permisos, trazabilidad, integración y rollback.', + sequences: [ + { + id: 'tecnico_arquitectura', + uiTitle: 'Arquitectura/permisos', + label: 'Arquitectura y permisos', + goal: 'explicar integración sin invadir', + expectedSignal: 'preguntas sobre permisos, datos, sistemas o alcance técnico', + nextAction: 'Alek prepara mapa técnico mínimo antes de pedir acceso o integración.', + cta: 'pedir revisión de mapa de integración', + step2: { + mapa: 'Va el mapa corto: WhatsApp Cloud API → ForgeChat (bandeja y reglas) → n8n (orquestación) → CRM y memoria por contacto. Todo con permisos mínimos, trazabilidad de cada mensaje y aprobación humana antes de cualquier envío sensible. Si quieres el detalle técnico completo, Alek te lo manda directo.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Alek me pidió contactarte porque eres quien cuida la parte técnica, y una revisión seria de IA360 empieza por permisos, datos, trazabilidad y rollback. ¿Cómo prefieres revisarlo?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_arquitectura:mapa', title: 'Mándame el mapa' }, + { id: 'seq_tecnico_arquitectura:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_arquitectura:ahora_no', title: 'Ahora no' }, + ], + }, + }, + { + id: 'tecnico_rollback', + uiTitle: 'Riesgos/rollback', + label: 'Riesgos / rollback', + goal: 'bajar objeción técnica', + expectedSignal: 'riesgo técnico prioritario o condición para prueba segura', + nextAction: 'Alek define guardrails técnicos antes de proponer piloto.', + cta: 'identificar riesgo técnico principal', + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Antes de hablar de funciones, Alek quiere entender qué riesgo técnico habría que controlar primero en una integración con IA360. ¿Cuál revisarías antes que nada?`, + openerOptions: { + kind: 'list', + button: 'Elegir riesgo', + options: [ + { id: 'seq_tecnico_rollback:permisos', title: 'Permisos' }, + { id: 'seq_tecnico_rollback:datos', title: 'Datos' }, + { id: 'seq_tecnico_rollback:trazabilidad', title: 'Trazabilidad' }, + { id: 'seq_tecnico_rollback:reversibilidad', title: 'Reversibilidad' }, + { id: 'seq_tecnico_rollback:dependencia', title: 'Dependencia operativa' }, + { id: 'seq_tecnico_rollback:alek_directo', title: 'Hablar con Alek' }, + ], + }, + }, + { + id: 'tecnico_integracion', + uiTitle: 'Integración controlada', + label: 'Integración controlada', + goal: 'definir prueba segura', + expectedSignal: 'condiciones para una prueba limitada y auditable', + nextAction: 'Alek confirma límites de piloto antes de tocar sistemas.', + cta: 'definir prueba técnica controlada', + step2: { + respondo: 'Te leo. Dime qué condición tendría que cumplirse para que la prueba te parezca segura (permisos, alcance, datos, reversibilidad) y la registro tal cual para Alek.', + }, + draft: ({ name }) => `Hola ${name}, soy la IA de Alek. Si hacemos una prueba técnica de IA360, Alek la quiere limitada, trazable y reversible. ¿Qué condición tendría que cumplirse para que te parezca segura?`, + openerOptions: { + kind: 'buttons', + options: [ + { id: 'seq_tecnico_integracion:respondo', title: 'Te respondo aquí' }, + { id: 'seq_tecnico_integracion:alek_directo', title: 'Que me escriba Alek' }, + { id: 'seq_tecnico_integracion:ahora_no', title: 'Ahora no' }, + ], + }, + }, + ], + }, +}; + +const IA360_TERMINAL_VCARD_CHOICES = { + guardar: { + personaContext: 'Solo guardar', + relationshipContext: 'solo_guardar', + flywheelPhase: 'Unknown', + riskLevel: 'low', + sequence: { + id: 'solo_guardar', + label: 'Captura sin acción', + goal: 'conservar contacto sin ruido ni riesgo', + expectedSignal: 'ninguna señal esperada; solo conservar contexto', + nextAction: 'No contactar. Alek puede reclasificar después si existe contexto.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda guardado sin acción externa.`, + }, + }, + excluir: { + personaContext: 'No contactar', + relationshipContext: 'no_contactar', + flywheelPhase: 'Unknown', + riskLevel: 'high', + sequence: { + id: 'no_contactar', + label: 'Bloqueo', + goal: 'respetar exclusión y evitar secuencia comercial', + expectedSignal: 'ninguna; bloqueo operativo', + nextAction: 'Mantener exclusión. No crear secuencia ni oportunidad comercial.', + cta: 'sin CTA', + copyStatus: 'blocked', + draft: ({ name }) => `No se genera borrador comercial para ${name}. El contacto queda marcado como no contactar.`, + }, + }, +}; + +function findIa360SequenceFlow(sequenceId) { + const target = String(sequenceId || '').toLowerCase(); + for (const flow of Object.values(IA360_PERSONA_SEQUENCE_FLOWS)) { + const sequence = flow.sequences.find(s => s.id === target); + if (sequence) return { flow, sequence }; + } + return null; +} + +function compactForWhatsApp(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function hasUnresolvedIa360Placeholder(text) { + return /\{\{\s*(?:\d+|nombre|referidor)[^}]*\}\}|\{\{[^}]+\}\}/i.test(String(text || '')); +} + +// Openers v2: saludo con primer nombre (D9). Limpia el sufijo de QA y toma el +// primer token; si no hay nada usable devuelve el valor original. +function ia360FirstNameFrom(name) { + const raw = String(name || '').trim().replace(/\s+WhatsApp IA360$/i, '').trim(); + return raw.split(/\s+/).filter(Boolean)[0] || raw; +} + +// Openers v2: arma el objeto `interactive` de un opener desde sequence.openerOptions +// (kind 'buttons' ≤3 opciones, kind 'list' 4+). Sin header ni footer: el copy +// aprobado por Alek va fiel en body.text. Devuelve null si la secuencia no tiene +// openerOptions (esas siguen saliendo como texto plano). +function buildIa360OpenerInteractive({ sequence, bodyText }) { + const opts = sequence && sequence.openerOptions; + if (!opts || !Array.isArray(opts.options) || !opts.options.length) return null; + if (opts.kind === 'list') { + return { + type: 'list', + body: { text: bodyText }, + action: { + button: opts.button || 'Elegir', + sections: [{ + title: 'Opciones', + rows: opts.options.map(o => ({ id: o.id, title: o.title, ...(o.description ? { description: o.description } : {}) })), + }], + }, + }; + } + return { + type: 'button', + body: { text: bodyText }, + action: { + buttons: opts.options.slice(0, 3).map(o => ({ type: 'reply', reply: { id: o.id, title: o.title } })), + }, + }; +} + +// ── G-C: ruteo real de respuestas seq_* (openers v2) ───────────────────────── +// Un botón/fila `seq_:` del catálogo persona-first SIEMPRE +// recibe un siguiente paso real: paso 2 definido en el catálogo (`step2`), +// manejo semántico compartido (alek_directo / ahora_no / horarios) o acuse +// específico con eco de la elección + aviso al owner con la nextAction de la +// secuencia. Devuelve true si lo manejó; false SOLO para ids seq_* que no están +// en el catálogo (esos sí caen al fallback global, porque son inválidos). +async function handleIa360SequenceReply({ record, replyId, contact = null }) { + const m = /^seq_([a-z0-9_]+):([a-z0-9_]+)$/.exec(String(replyId || '').trim().toLowerCase()); + if (!m) return false; + const sequenceId = m[1]; + const optionKey = m[2]; + const found = findIa360SequenceFlow(sequenceId); + if (!found) return false; + const { sequence } = found; + const option = (sequence.openerOptions?.options || []) + .find(o => String(o.id).toLowerCase() === `seq_${sequenceId}:${optionKey}`); + if (!option) return false; + try { + const ctx = contact || await loadIa360ContactContext(record).catch(() => null); + const cf = ctx?.custom_fields || {}; + const contactName = ctx?.name || record.contact_name || record.contact_number; + const safeName = sanitizeIa360IntroName(contactName) || record.contact_number; + const nowIso = new Date().toISOString(); + + // Guard de estado (paridad con el router 100M): si la conversación ya avanzó + // a agenda/reunión/handoff humano, un botón seq_* de un opener viejo NO mueve + // el deal hacia atrás; responde continuidad y el owner se entera del tap. + const guard = await ia360HundredMAdvancedGuard(record); + if (guard.advanced) { + await enqueueIa360Text({ record, label: `ia360_seq_continuity_${sequenceId}`, body: guard.body }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_stale_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) tocó "${option.title}" de un opener viejo ("${sequence.label}"), pero su proceso ya va más adelante. No moví nada; le respondí con continuidad.`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] stale notify:', e.message)); + return true; + } + + // Dedupe de doble tap del contacto: misma secuencia+opción ya registrada → + // continuidad corta, sin re-registro ni avisos duplicados al owner. + const prev = cf.ia360_seq_last_response || null; + if (prev && prev.sequence === sequenceId && prev.option === optionKey) { + await enqueueIa360Text({ + record, + label: `ia360_seq_dup_${sequenceId}`, + body: `Ya tengo registrada tu respuesta "${option.title}" y Alek ya tiene el contexto. Quedo al pendiente; cualquier cosa me escribes por aquí.`, + }); + return true; + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['ia360-seq-respuesta', `seq-${sequenceId}`], + customFields: { + ia360_seq_last_response: { sequence: sequenceId, option: optionKey, title: option.title, at: nowIso }, + ia360_ultima_respuesta: option.title, + ultimo_cta_enviado: `ia360_seq_reply_${sequenceId}_${optionKey}`, + }, + }).catch(e => console.error('[ia360-seq] merge state:', e.message)); + + const notifyOwner = (detalle) => sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_seq_reply_${sequenceId}`, + body: `Alek, ${safeName} (${record.contact_number}) respondió "${option.title}" al opener "${sequence.label}". ${detalle}`, + targetContact: record.contact_number, + ownerBudget: true, + }).catch(e => console.error('[ia360-seq] notify owner:', e.message)); + + // 1) Salida directa con Alek. + if (optionKey === 'alek_directo') { + await enqueueIa360Text({ + record, + label: `ia360_seq_alek_directo_${sequenceId}`, + body: 'Perfecto, le aviso a Alek ahora mismo para que te escriba directo. Gracias por responder.', + }); + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió hablar directo con Alek.`, + }).catch(e => console.error('[ia360-seq] deal alek_directo:', e.message)); + await notifyOwner('Pidió que le escribas TÚ directo. Deal en "Requiere Alek".'); + return true; + } + + // 2) Cierre suave → nutrición. + if (optionKey === 'ahora_no') { + await enqueueIa360Text({ + record, + label: `ia360_seq_ahora_no_${sequenceId}`, + body: 'De acuerdo, no te insisto. Si más adelante quieres retomarlo, me escribes por aquí y seguimos donde lo dejamos.', + }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['nutricion-suave'], + customFields: {}, + }).catch(e => console.error('[ia360-seq] tag nutricion:', e.message)); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: ahora no. Pasa a nutrición suave.`, + }).catch(e => console.error('[ia360-seq] deal ahora_no:', e.message)); + await notifyOwner('Respondió que ahora no; queda en nutrición suave.'); + return true; + } + + // 3) Agenda con permiso (referido_permiso_agenda:horarios). + if (optionKey === 'horarios') { + await enqueueIa360Interactive({ + record, + label: `ia360_seq_horarios_${sequenceId}`, + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + body: { text: 'Perfecto. ¿Qué ventana te acomoda mejor para la llamada con Alek?' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: pidió horarios. Deal a "Agenda en proceso".`, + }).catch(e => console.error('[ia360-seq] deal horarios:', e.message)); + await notifyOwner('Pidió horarios para una llamada contigo. Deal en "Agenda en proceso".'); + return true; + } + + // 4) Paso 2 definido en el catálogo. + const step2 = sequence.step2 && sequence.step2[optionKey]; + if (step2) { + await enqueueIa360Text({ + record, + label: `ia360_seq_step2_${sequenceId}_${optionKey}`, + body: step2, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Paso 2 de la secuencia enviado.`, + }).catch(e => console.error('[ia360-seq] deal step2:', e.message)); + await notifyOwner(`Le envié el paso 2 de la secuencia. Next action sugerida: ${sequence.nextAction}`); + return true; + } + + // 5) Sin paso 2 en el catálogo (temas de lista): acuse específico con eco de + // la elección + aviso al owner con la respuesta y la next action sugerida. + await enqueueIa360Text({ + record, + label: `ia360_seq_ack_${sequenceId}_${optionKey}`, + body: `Gracias, registré tu respuesta: "${option.title}". Le paso este contexto a Alek para que el siguiente paso vaya directo a eso, sin rodeos. Te escribe él con una propuesta concreta.`, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: sequence.label, + notes: `Respuesta al opener ${sequence.id}: "${option.title}". Acuse enviado; siguiente paso con Alek.`, + }).catch(e => console.error('[ia360-seq] deal ack:', e.message)); + await notifyOwner(`Next action sugerida: ${sequence.nextAction}`); + return true; + } catch (err) { + console.error('[ia360-seq] reply error:', err.message); + // Nunca mudo: acuse mínimo aunque el registro haya fallado. + await enqueueIa360Text({ + record, + label: 'ia360_seq_ack_error', + body: 'Recibí tu respuesta y ya se la pasé a Alek. Te escribe él en corto.', + }).catch(() => {}); + return true; + } +} + +// ── G-C: CTAs únicos — alias de botones de template (quick replies de texto) ── +// Los templates fríos (p. ej. ia360_referido_apertura = template 41, +// ia360_aliado_mapa_colaboracion = template 43) llegan con button.payload = +// TEXTO del botón, no un id estructurado, por lo que "Sí, cuéntame" era ambiguo +// entre Revenue OS y Referidos. Revenue OS se resuelve ANTES en el dispatch +// (handleRevenueOsButton, gateado por ia360_revenue_state); si no era suyo, este +// alias traduce el texto al id seq_* ÚNICO de la secuencia persona-first cuyo +// opener realmente se le envió al contacto (pf.sequence_candidate.id + pf.send). +const IA360_SEQ_ALIAS_NEGATIVE = new Set(['ahora no', 'por ahora no', 'no por ahora']); +const IA360_SEQ_ALIAS_HANDOFF = new Set(['que me escriba alek', 'hablar con alek']); +// Solo frases genuinamente afirmativas. Los títulos exactos del catálogo +// ("Proponme horarios", "Te respondo aquí", etc.) se resuelven por match de +// título, no por semántica: ponerlos aquí fabricaría elecciones equivocadas. +const IA360_SEQ_ALIAS_AFFIRMATIVE = new Set([ + 'sí, cuéntame', 'si, cuéntame', 'sí, cuentame', 'si, cuentame', + 'sí, cuéntame más', 'si, cuentame mas', + 'sí, pregúntame', 'si, preguntame', + 'sí, mándalo', 'si, mandalo', + 'sí, compártelo', 'si, compartelo', + 'sí, a ver', 'si, a ver', + 'sí, te cuento', 'si, te cuento', + 'sí, hay un tema', 'si, hay un tema', + 'me interesa', 'sí, me interesa', 'si, me interesa', +]); + +function resolveIa360TemplateButtonAlias({ replyId, contact }) { + const key = String(replyId || '').trim().toLowerCase(); + if (!key || key.startsWith('seq_')) return null; + const isNeg = IA360_SEQ_ALIAS_NEGATIVE.has(key); + const isHand = IA360_SEQ_ALIAS_HANDOFF.has(key); + const isAff = IA360_SEQ_ALIAS_AFFIRMATIVE.has(key); + if (!isNeg && !isHand && !isAff) return null; + const pf = contact?.custom_fields?.ia360_persona_first; + const seqId = pf?.sequence_candidate?.id; + if (!seqId || !pf?.send?.sent_at) return null; // solo si su opener realmente salió + const found = findIa360SequenceFlow(seqId); + if (!found) return null; + const opts = found.sequence.openerOptions?.options || []; + // 1) Match exacto por título visible del botón. + const byTitle = opts.find(o => String(o.title).trim().toLowerCase() === key); + if (byTitle) return String(byTitle.id).toLowerCase(); + // 2) Por semántica: negativo → ahora_no; handoff → alek_directo; afirmativo → + // la primera opción que no sea ninguna de las dos (el camino afirmativo). + const bySuffix = (suffix) => opts.find(o => String(o.id).toLowerCase().endsWith(`:${suffix}`)); + if (isNeg) { const o = bySuffix('ahora_no'); return o ? String(o.id).toLowerCase() : null; } + if (isHand) { const o = bySuffix('alek_directo'); return o ? String(o.id).toLowerCase() : null; } + // Afirmativo SOLO cuando es inequívoco: la secuencia declara su opción de + // template (templateAliasOption) o existe exactamente UNA opción no terminal. + // Con varias opciones posibles (listas de temas) NO se fabrica una elección: + // se devuelve null y el fallback global acusa recibo y avisa al owner. + if (found.sequence.templateAliasOption) { + const o = bySuffix(found.sequence.templateAliasOption); + if (o) return String(o.id).toLowerCase(); + } + const nonTerminal = opts.filter(o => { + const id = String(o.id).toLowerCase(); + return !id.endsWith(':ahora_no') && !id.endsWith(':alek_directo'); + }); + return nonTerminal.length === 1 ? String(nonTerminal[0].id).toLowerCase() : null; +} + +// ── G-C: anti-loop del router 100M ─────────────────────────────────────────── +// Nodos que en las pruebas reales generaron ciclos (doc 2026-06-10, chat_history +// 1068-1079 y 1135-1142): exploración, mecanismos, mapa y ejemplo. Una visita +// repetida ya no reenvía el bloque completo: responde una versión condensada con +// salidas terminales (agendar / llamada / más adelante). +const IA360_100M_LOOP_PRONE = new Set([ + 'explorando', + 'mecanismo-whatsapp-crm', + 'mecanismo-erp-bi', + 'mecanismo-agentic-followup', + 'mapa-30-60-90-solicitado', + 'ejemplo-solicitado', +]); +// Etapas donde la conversación ya avanzó a agenda/handoff humano: un botón 100M +// de un mensaje viejo NO debe reabrir la rama (guard de estado/versión). +const IA360_100M_ADVANCED_STAGES = new Set(['Agenda en proceso', 'Reunión agendada', 'Requiere Alek']); + +async function ia360HundredMAdvancedGuard(record) { + const out = { advanced: false, body: '', visited: {}, visitedOk: false }; + try { + const contact = await loadIa360ContactContext(record).catch(() => null); + const cf = contact?.custom_fields || {}; + if (contact) { + out.visited = (cf.ia360_100m_visited && typeof cf.ia360_100m_visited === 'object') ? cf.ia360_100m_visited : {}; + out.visitedOk = true; // lectura confiable: se puede escribir sin pisar el mapa + } + // Solo reuniones FUTURAS cuentan como "en curso": el cache crudo ia360_bookings + // conserva citas pasadas y atraparía al contacto para siempre. + const bookings = await loadIa360BookingsForList(record.contact_number).catch(() => []); + const hasBooking = Array.isArray(bookings) && bookings.length > 0; + let stageName = ''; + const deal = await getActiveNonTerminalIa360Deal(record).catch(() => null); + if (deal) stageName = deal.stage_name || ''; + if (hasBooking || IA360_100M_ADVANCED_STAGES.has(stageName)) { + out.advanced = true; + out.body = (hasBooking || stageName === 'Reunión agendada') + ? 'Vi tu respuesta, pero tu proceso ya va más adelante: tienes una reunión en curso con Alek. Sigo con eso para no regresarte al inicio. Si quieres mover la reunión o retomar otro tema, dímelo por aquí.' + : 'Vi tu respuesta a un mensaje anterior, pero tu proceso ya va más adelante: estamos en la parte de agenda con Alek. Sigo con eso para no darte vueltas; si quieres retomar otro tema, dímelo por aquí y lo vemos.'; + } + } catch (err) { + console.error('[ia360-100m] advanced guard:', err.message); + } + return out; +} + +function buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction = 'sequence_selected' }) { + const customFields = contact?.custom_fields || {}; + const name = contact?.name || targetContact; + // Openers v2: saludo con primer nombre (D9) + quién hizo la introducción (D6). + const draftName = ia360FirstNameFrom(name); + const quienIntro = String(customFields.quien_intro || '').trim() || null; + const draft = typeof sequence.draft === 'function' ? sequence.draft({ name: draftName, quienIntro }) : String(sequence.draft || ''); + const relationshipContext = flow.relationshipContext || ''; + const isCapturedOnly = relationshipContext === 'solo_guardar'; + const isDoNotContact = relationshipContext === 'no_contactar'; + const copyStatus = isCapturedOnly + ? 'captured_only' + : hasUnresolvedIa360Placeholder(draft) ? 'blocked' : (sequence.copyStatus || 'draft'); + const approvalStatus = isCapturedOnly ? 'no_action' : isDoNotContact ? 'do_not_contact' : 'requires_alek'; + const approvalReason = isCapturedOnly + ? 'Captura sin acción: no existe borrador ni intento de envío por aprobar.' + : isDoNotContact + ? 'Exclusión operativa: no contactar ni crear secuencia.' + : 'Requiere aprobación humana antes de cualquier envío externo.'; + const currentBlock = isCapturedOnly + ? 'captured_only' + : isDoNotContact ? 'do_not_contact' : 'requires_human_approval'; + return { + schema: 'persona_first_vcard.v1', + request_id: `${record.message_id || 'owner'}:${targetContact}:${sequence.id}`, + source: 'forgechat_b29_vcard_intake', + received_at: customFields.captured_at || record.timestamp || new Date().toISOString(), + dry_run: true, + requires_human_approval: !(isCapturedOnly || isDoNotContact), + owner: { + wa_id: IA360_OWNER_NUMBER, + role: 'Alek', + }, + contact: { + forgechat_contact_id: contact?.id || '', + espo_contact_id: customFields.espo_id || '', + wa_id: customFields.vcard_wa_id || targetContact, + phone_e164: targetContact ? `+${targetContact}` : '', + name, + email: customFields.email || '', + source_message_id: customFields.source_message_id || '', + staged: true, + consent_status: flow.relationshipContext === 'no_contactar' ? 'do_not_contact' : 'unknown', + }, + identity: { + dedupe_method: 'wa_number_contact_number', + confidence: contact?.id ? 0.85 : 0.5, + existing_relationship: flow.personaContext || '', + matched_records: [], + }, + classification: { + persona_context: flow.personaContext, + relationship_context: flow.relationshipContext, + flywheel_phase: flow.flywheelPhase, + intent: ownerAction, + risk_level: flow.riskLevel || 'medium', + notes: flow.notes || '', + }, + sequence_candidate: { + id: sequence.id, + label: sequence.label, + goal: sequence.goal, + product: 'IA360', + proof_asset: sequence.proofAsset || '', + cta: sequence.cta || '', + copy_status: copyStatus, + media_status: 'not_required', + crm_expected_state: 'no_opportunity_auto', + draft, + }, + approval: { + status: approvalStatus, + approved_by: '', + approved_at: '', + reason: approvalReason, + }, + guardrail: { + current_block: currentBlock, + external_send_allowed: false, + allowed_recipient: 'owner_only', + }, + learning: { + expected_signal: sequence.expectedSignal || '', + response_summary: '', + objection: '', + next_step: sequence.nextAction || '', + update_crm: false, + update_memory: false, + }, + }; +} + +function describeIa360CurrentBlock(payload) { + const status = payload?.approval?.status || ''; + if (status === 'no_action') return 'captura sin acción; no hay borrador ni envío externo al contacto.'; + if (status === 'do_not_contact') return 'no contactar; exclusión operativa sin envío externo al contacto.'; + return 'requiere aprobación humana; no hay envío externo al contacto.'; +} + +function buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }) { + return [ + 'Readout IA360 persona-first', + '', + `Contacto: ${name} (${targetContact})`, + `Persona: ${flow.personaContext}`, + `Fase flywheel sugerida: ${flow.flywheelPhase}`, + `Logro esperado del flujo: ${sequence.goal}`, + `Secuencia elegida: ${sequence.label} (${sequence.id})`, + '', + 'Borrador propuesto:', + payload.sequence_candidate.draft, + ...(sequence.openerOptions && Array.isArray(sequence.openerOptions.options) + ? ['', `Opciones del mensaje (${sequence.openerOptions.kind === 'list' ? 'lista' : 'botones'}): ${sequence.openerOptions.options.map(o => o.title).join(' | ')}`] + : []), + '', + `Bloqueo actual: ${describeIa360CurrentBlock(payload)}`, + `Siguiente acción recomendada: ${sequence.nextAction || 'Alek revisa y aprueba solo si el contexto lo justifica.'}`, + ].join('\n'); +} + +async function loadIa360OwnerReplyContext({ record }) { + if (!record?.context_message_id) return { ok: false, reason: 'missing_context_message_id' }; + const { rows } = await pool.query( + `SELECT message_id, message_body, template_meta->>'label' AS label + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND message_id=$3 + AND template_meta->>'ux'='ia360_owner' + LIMIT 1`, + [record.wa_number, IA360_OWNER_NUMBER, record.context_message_id] + ); + if (!rows.length) return { ok: false, reason: 'context_owner_message_not_found' }; + return { ok: true, row: rows[0], label: rows[0].label || '' }; +} + +async function blockIa360OwnerContextMismatch({ record, targetContact, action, reason, expectedLabel }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-owner-context-blocked'], + customFields: { + ia360_owner_reply_blocked_at: new Date().toISOString(), + ia360_owner_reply_blocked_action: action || '', + ia360_owner_reply_blocked_reason: reason || '', + ia360_owner_reply_expected_context: expectedLabel || '', + }, + }).catch(e => console.error('[ia360-owner-context] persist block:', e.message)); + } + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_context_mismatch_blocked', + body: `Bloqueé esa acción IA360 porque el botón no coincide con el mensaje/contexto esperado para ${targetContact || 'ese contacto'}. No envié nada al contacto.`, + targetContact, + ownerBudget: true, + }); +} + +async function validateIa360OwnerContext({ record, targetContact, action, expectedLabelPrefix }) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + return { ok: false, reason: 'not_owner_contact' }; + } + const ctx = await loadIa360OwnerReplyContext({ record }); + if (!ctx.ok) return ctx; + if (expectedLabelPrefix && !String(ctx.label || '').startsWith(expectedLabelPrefix)) { + return { ok: false, reason: 'context_label_mismatch', label: ctx.label || '' }; + } + return { ok: true, label: ctx.label || '', row: ctx.row }; +} + +async function persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags = [] }) { + const stage = + flow.relationshipContext === 'no_contactar' ? 'No contactar' + : flow.relationshipContext === 'solo_guardar' ? 'Capturado / Sin acción' + : sequence.id === 'persona_selected' ? 'Persona seleccionada / Por secuencia' + : 'Requiere Alek'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-persona-first', `persona:${flow.relationshipContext}`, `sequence:${sequence.id}`, ...tags], + customFields: { + staged: true, + stage, + persona_context: flow.personaContext, + fase_flywheel: flow.flywheelPhase, + sequence_candidate: sequence.id, + owner_action: payload.classification.intent, + owner_action_at: new Date().toISOString(), + ia360_persona_first: payload, + }, + }); +} + +// G-D: pipelines donde un deal vivo habilita la jugada de expansión (D7). +const IA360_EXPANSION_PIPELINES = ['IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión']; + +// G-D: señales reales del contacto para el ranker del selector de secuencias. +// Cada consulta tiene su propio try/catch (fail-open): si la DB falla, esa señal +// queda en null y el selector sale con el orden default — nunca mudo. +// OJO: ia360_memory_* tiene doble keying en contact_wa_number (a veces la línea +// del bot, a veces el número del contacto); la llave confiable es contact_number. +async function gatherIa360ContactSignals({ waNumber, contactNumber }) { + const signals = { liveDeal: null, quienIntro: null, lastFact: null, lastEvent: null, lastIncomingAt: null }; + try { + const { rows } = await pool.query( + `SELECT d.title, p.name AS pipeline_name, s.name AS stage_name + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + JOIN coexistence.pipeline_stages s ON s.id = d.stage_id + WHERE d.contact_wa_number = $1 AND d.contact_number = $2 AND d.status = 'open' + ORDER BY d.updated_at DESC NULLS LAST, d.id DESC + LIMIT 1`, + [waNumber, contactNumber] + ); + if (rows.length) signals.liveDeal = { title: rows[0].title, pipelineName: rows[0].pipeline_name, stageName: rows[0].stage_name }; + } catch (e) { console.error('[ia360-rank] deal lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT custom_fields->>'quien_intro' AS quien_intro, custom_fields->>'referido_por' AS referido_por + FROM coexistence.contacts + WHERE wa_number = $1 AND contact_number = $2 + LIMIT 1`, + [waNumber, contactNumber] + ); + const quienIntro = String(rows[0]?.quien_intro || '').trim(); + if (quienIntro) { + signals.quienIntro = quienIntro; + } else { + // referido_por guarda el NÚMERO de quien compartió el vCard. Solo cuenta + // como introductor si NO es el owner, ni el bot, ni el propio contacto; + // y solo con un nombre presentable (no citamos números pelones). + const referidoPor = normalizePhone(String(rows[0]?.referido_por || '').trim()); + if (referidoPor && referidoPor !== IA360_OWNER_NUMBER && referidoPor !== normalizePhone(waNumber) && referidoPor !== normalizePhone(contactNumber)) { + const { rows: introRows } = await pool.query( + `SELECT name, profile_name FROM coexistence.contacts WHERE wa_number = $1 AND contact_number = $2 LIMIT 1`, + [waNumber, referidoPor] + ); + const introName = String(introRows[0]?.name || introRows[0]?.profile_name || '').trim(); + if (introName) signals.quienIntro = introName; + } + } + } catch (e) { console.error('[ia360-rank] quien_intro lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT COALESCE(recurring_pain, preference, objection, role) AS texto, last_seen_at + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + AND COALESCE(recurring_pain, preference, objection, role) IS NOT NULL + ORDER BY last_seen_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastFact = { text: rows[0].texto, lastSeenAt: rows[0].last_seen_at }; + } catch (e) { console.error('[ia360-rank] facts lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT summary, created_at + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC + LIMIT 1`, + [contactNumber] + ); + if (rows.length) signals.lastEvent = { summary: rows[0].summary, createdAt: rows[0].created_at }; + } catch (e) { console.error('[ia360-rank] events lookup:', e.message); } + try { + const { rows } = await pool.query( + `SELECT MAX(created_at) AS last_in + FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 AND direction = 'incoming'`, + [waNumber, contactNumber] + ); + if (rows[0]?.last_in) signals.lastIncomingAt = rows[0].last_in; + } catch (e) { console.error('[ia360-rank] chat_history lookup:', e.message); } + return signals; +} + +// G-D: ranker rule-based (SIN LLM). Solo REORDENA las secuencias de la persona +// elegida; cada razón cita una señal que existe en la base. Sin señales que +// matcheen secuencias de esta persona → orden de catálogo y cero razones +// inventadas (honestidad del ranker). +function rankIa360Sequences({ flow, signals }) { + const scores = new Map(); + const reasons = new Map(); + const bump = (id, pts, reason) => { + scores.set(id, (scores.get(id) || 0) + pts); + if (reason && !reasons.has(id)) reasons.set(id, reason); + }; + const s = signals || {}; + if (s.liveDeal) { + const dealReason = `Deal vivo «${s.liveDeal.title}» en ${s.liveDeal.pipelineName}`; + if (IA360_EXPANSION_PIPELINES.includes(s.liveDeal.pipelineName)) { + bump('cliente_expansion', 35, dealReason); + bump('cliente_readout', 20, dealReason); + bump('cliente_soporte', 10, dealReason); + } else { + bump('cliente_readout', 30, dealReason); + bump('cliente_soporte', 20, dealReason); + } + } + if (s.quienIntro) { + const introReason = `Te lo presentó ${s.quienIntro}`; + bump('referido_contexto', 30, introReason); + bump('referido_permiso_agenda', 15, introReason); + bump('referido_oneliner', 10, introReason); + } + const memorySignal = s.lastEvent || s.lastFact; + if (memorySignal) { + // 40 y no más: "Sugerida: Memoria registrada: " + frag debe caber en los + // 72 chars de la description de Meta sin perder el final de la razón. + const frag = compactForWhatsApp(s.lastEvent ? s.lastEvent.summary : s.lastFact.text, 40); + const memReason = `Memoria registrada: ${frag}`; + bump('beta_memoria', 15, memReason); + bump('cliente_readout', 10, memReason); + } + const ordered = (flow.sequences || []) + .map((seq, idx) => ({ seq, idx, score: scores.get(seq.id) || 0 })) + .sort((a, b) => (b.score - a.score) || (a.idx - b.idx)); + const ranked = ordered.length > 0 && ordered[0].score > 0; + return { + ordered: ordered.map(o => o.seq), + suggestedId: ranked ? ordered[0].seq.id : null, + reasonFor: (id) => reasons.get(id) || null, + ranked, + }; +} + +// G-D: resumen de 2 líneas del contacto para el cuerpo de la tarjeta. Solo +// afirma lo que existe; sin señales devuelve una sola línea honesta. +function buildIa360ContactSummaryLines(signals) { + const s = signals || {}; + const fmtDate = (d) => { + try { return new Date(d).toISOString().slice(0, 10); } catch { return ''; } + }; + if (!s.liveDeal && !s.quienIntro && !s.lastFact && !s.lastEvent) { + return ['Aún no tengo señales registradas de este contacto (sin deal, sin memoria, sin introductor).']; + } + // Tope de 180: título/pipeline/etapa vienen de la base sin límite y el body + // del interactive de Meta admite 1024 como máximo — si se excede, la tarjeta + // se vuelve muda. Acotado aquí, el body completo queda siempre < 1024. + const line1 = s.liveDeal + ? compactForWhatsApp(`Deal vivo: «${s.liveDeal.title}» — ${s.liveDeal.pipelineName}${s.liveDeal.stageName ? ` / ${s.liveDeal.stageName}` : ''}.`, 180) + : 'Sin deal vivo registrado.'; + let line2; + if (s.lastEvent) { + const fecha = fmtDate(s.lastEvent.createdAt); + line2 = `Último evento${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastEvent.summary, 120)}`; + } else if (s.lastFact) { + const fecha = fmtDate(s.lastFact.lastSeenAt); + line2 = `Memoria${fecha ? ` (${fecha})` : ''}: ${compactForWhatsApp(s.lastFact.text, 120)}`; + } else if (s.quienIntro) { + line2 = `Lo presentó: ${s.quienIntro}.`; + } else if (s.lastIncomingAt) { + line2 = `Última interacción: ${fmtDate(s.lastIncomingAt)}.`; + } else { + line2 = 'Sin memoria registrada todavía.'; + } + return [line1, line2]; +} + +async function sendIa360SequenceSelector({ record, targetContact, contact, flowKey, flow }) { + const name = contact?.name || targetContact; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow, + sequence: { + id: 'persona_selected', + label: 'Persona seleccionada', + goal: 'elegir una secuencia lógica por persona antes de redactar', + expectedSignal: 'Alek selecciona el flujo correcto', + nextAction: 'Elegir una secuencia filtrada por persona.', + cta: 'elegir secuencia', + copyStatus: 'draft', + draft: () => 'Pendiente: Alek debe elegir una secuencia antes de generar borrador.', + }, + ownerAction: 'persona_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow, + sequence: payload.sequence_candidate, + payload, + tags: [`persona-choice:${flowKey}`], + }); + // G-D: ranker rule-based sobre señales reales — la sugerida primero con el + // porqué en su descripción; sin señales → orden de catálogo sin razones. + const signals = await gatherIa360ContactSignals({ waNumber: record.wa_number, contactNumber: targetContact }); + const ranking = rankIa360Sequences({ flow, signals }); + const summaryLines = buildIa360ContactSummaryLines(signals); + const bodyText = [ + `Alek, ${name} quedó como ${flow.personaContext}.`, + ...summaryLines, + 'Elige una secuencia. Sigo en dry-run: no enviaré nada al contacto.', + ].join('\n'); + const suggestedReason = ranking.suggestedId ? ranking.reasonFor(ranking.suggestedId) : null; + // G-D: el ranking queda auditable en custom_fields (orden, sugerida, razón, + // resumen) — best-effort, no bloquea el envío de la tarjeta. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + customFields: { + ia360_selector_ranking: { + at: new Date().toISOString(), + persona: flowKey, + ranked: ranking.ranked, + suggested: ranking.suggestedId, + reason: suggestedReason, + order: ranking.ordered.map(seq => seq.id), + summary: summaryLines, + }, + }, + }).catch(e => console.error('[ia360-rank] persist ranking:', e.message)); + return sendOwnerInteractive({ + record, + label: `owner_sequence_selector_${targetContact}_${flowKey}`, + messageBody: ranking.ranked + ? `IA360: secuencias ${name} — sugerida: ${ranking.suggestedId} (${suggestedReason})` + : `IA360: secuencias ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Elegir secuencia' }, + body: { text: bodyText }, + footer: { + text: ranking.ranked + ? 'Sugerida primero por señales; aprobación antes de envío' + : 'Persona antes de secuencia; aprobación antes de envío', + }, + action: { + button: 'Elegir secuencia', + sections: [{ + title: compactForWhatsApp(flow.personaContext, 24), + rows: ranking.ordered.map(seq => { + const reason = ranking.suggestedId === seq.id ? ranking.reasonFor(seq.id) : null; + return { + id: `owner_seq:${targetContact}:${seq.id}`, + title: compactForWhatsApp(seq.uiTitle || seq.label, 24), + description: compactForWhatsApp(reason ? `Sugerida: ${reason}` : seq.goal, 72), + }; + }), + }], + }, + }, + }); +} + +async function handleIa360PersonaChoice({ record, targetContact, personaChoice }) { + const flow = IA360_PERSONA_SEQUENCE_FLOWS[personaChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!flow) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_persona_unknown', body: `No reconozco la persona elegida para ${name}. No envié nada y queda para revisión de Alek.`, targetContact, ownerBudget: true }); + return; + } + await sendIa360SequenceSelector({ record, targetContact, contact, flowKey: personaChoice, flow }); +} + +async function handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_missing_target', body: 'No encontré el número del contacto para esa secuencia. No envié nada.' }); + return; + } + const found = findIa360SequenceFlow(sequenceId); + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_sequence_unknown', body: `La secuencia elegida para ${name} no está en el catálogo persona-first. No envié nada.`, targetContact, ownerBudget: true }); + return; + } + const { flow, sequence } = found; + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_seq', + expectedLabelPrefix: `owner_sequence_selector_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: ctx.reason, + expectedLabel: `owner_sequence_selector_${targetContact}_*`, + }); + return; + } + const contextFlowKey = String(ctx.label || '').slice(`owner_sequence_selector_${targetContact}_`.length); + const contextFlow = IA360_PERSONA_SEQUENCE_FLOWS[contextFlowKey]; + const previousRel = contact?.custom_fields?.ia360_persona_first?.classification?.relationship_context || ''; + if ((contextFlow && contextFlow.relationshipContext !== flow.relationshipContext) || + (previousRel && previousRel !== flow.relationshipContext)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_seq', + reason: 'sequence_persona_mismatch', + expectedLabel: `persona=${contextFlowKey || previousRel}`, + }); + return; + } + const payload = buildIa360PersonaPayload({ record, contact, targetContact, flow, sequence, ownerAction: 'sequence_selected' }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow, sequence, payload }); + if (hasUnresolvedIa360Placeholder(readout)) { + payload.sequence_candidate.copy_status = 'blocked'; + payload.approval.reason = 'Borrador bloqueado por placeholder sin resolver.'; + } + await persistIa360PersonaPayload({ record, targetContact, flow, sequence, payload, tags: ['owner-sequence-selected'] }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_sequence_readout_${sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + // APPROVE-SEND: tras el readout, el owner decide con una tarjeta (mismo patrón + // que la tarjeta de cancelación). Solo si el payload realmente requiere + // aprobación humana (no para solo_guardar / no_contactar / copy bloqueado). + if (payload.approval.status === 'requires_alek' && payload.sequence_candidate.copy_status !== 'blocked') { + await sendIa360ApproveCard({ record, targetContact, name, flow, sequence }); + } +} + +// ============================================================================ +// APPROVE-SEND — "último metro" del P0: el owner aprueba y el opener de la +// secuencia sale al CONTACTO (egress único vía messageSender/sendQueue). +// Gate de seguridad: solo números en IA360_APPROVE_SEND_ALLOWLIST (env, CSV). +// Sin allowlist o fuera de ella → solo readout, NUNCA envía. +// ============================================================================ + +function ia360ApproveSendAllowlist() { + return String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '') + .split(',') + .map(s => s.replace(/\D/g, '')) + .filter(Boolean); +} + +async function sendIa360ApproveCard({ record, targetContact, name, flow, sequence }) { + return sendOwnerInteractive({ + record, + label: `owner_approve_card_${targetContact}_${sequence.id}`, + messageBody: `IA360: aprobar envío a ${name}`, + targetContact, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: 'Aprobar envío' }, + body: { + text: `Alek, el borrador para ${name} (${flow.personaContext}, secuencia ${sequence.label}) está arriba en el readout. ¿Qué hago?`, + }, + footer: { text: 'Solo envío con tu aprobación explícita' }, + action: { + button: 'Decidir', + sections: [{ + title: 'Acciones', + rows: [ + { id: `owner_approve_send:${targetContact}:${sequence.id}`, title: 'Aprobar y enviar', description: 'Envío el opener al contacto y avanzo el pipeline' }, + { id: `owner_approve_edit:${targetContact}:${sequence.id}`, title: 'Editar copy', description: 'Queda en borrador; lo editas antes de enviar' }, + { id: `owner_approve_keep:${targetContact}:${sequence.id}`, title: 'Solo guardar', description: 'Captura sin envío ni secuencia' }, + { id: `owner_approve_dnc:${targetContact}:${sequence.id}`, title: 'No contactar', description: 'Exclusión operativa, sin envío' }, + { id: `owner_approve_manual:${targetContact}:${sequence.id}`, title: 'Tomar manual', description: 'Tú le escribes; muevo el deal a Requiere Alek' }, + ], + }], + }, + }, + }); +} + +async function ia360ApproveSendDeny({ record, targetContact, reason, body }) { + if (targetContact) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send-blocked'], + customFields: { + ia360_approve_send_blocked_at: new Date().toISOString(), + ia360_approve_send_blocked_reason: reason, + }, + }).catch(e => console.error('[ia360-approve] persist deny:', e.message)); + } + console.warn('[ia360-approve] blocked target=%s reason=%s', targetContact || '-', reason); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_blocked', + body, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveSend({ record, targetContact, sequenceId }) { + const deny = (reason, body) => ia360ApproveSendDeny({ record, targetContact, reason, body }); + if (!targetContact) return deny('missing_target', 'No encontré el número del contacto de esa aprobación. No envié nada.'); + if (isIa360OwnerNumber(targetContact)) return deny('target_is_owner', 'Ese número es el tuyo (owner). No envío secuencias al owner.'); + if (normalizePhone(targetContact) === normalizePhone(record.wa_number)) return deny('target_is_system_number', 'Ese número es el del propio bot. No envié nada.'); + + const found = findIa360SequenceFlow(sequenceId); + if (!found) return deny('unknown_sequence', `La secuencia "${sequenceId}" no está en el catálogo persona-first. No envié nada.`); + const { flow, sequence } = found; + + // Contexto: el tap debe responder a la tarjeta de aprobación de ESTE contacto+secuencia. + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_approve_send', + expectedLabelPrefix: `owner_approve_card_${targetContact}_`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: ctx.reason, + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + const cardSeq = String(ctx.label || '').slice(`owner_approve_card_${targetContact}_`.length); + if (cardSeq !== String(sequenceId)) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_approve_send', + reason: 'card_sequence_mismatch', + expectedLabel: `owner_approve_card_${targetContact}_${sequenceId}`, + }); + return; + } + + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + if (!contact) return deny('contact_not_found', `No encontré al contacto ${targetContact} en la base. No envié nada.`); + const name = contact.name || targetContact; + + // do_not_contact: por tag o por estado persona-first previo. + const { rows: dncRows } = await pool.query( + `SELECT (tags ? 'no-contactar') AS dnc FROM coexistence.contacts WHERE wa_number=$1 AND contact_number=$2 LIMIT 1`, + [record.wa_number, targetContact] + ); + const pf = contact.custom_fields?.ia360_persona_first || null; + if (dncRows[0]?.dnc || pf?.classification?.relationship_context === 'no_contactar' || pf?.contact?.consent_status === 'do_not_contact') { + return deny('do_not_contact', `${name} está marcado como NO CONTACTAR. No envié nada.`); + } + + // El estado persistido debe coincidir con el último readout (misma secuencia). + if (!pf || pf.sequence_candidate?.id !== String(sequenceId)) { + return deny('readout_state_mismatch', `El estado guardado de ${name} no coincide con el último readout (${sequenceId}). Repite la selección de secuencia. No envié nada.`); + } + if (pf.sequence_candidate.copy_status === 'blocked') { + return deny('copy_blocked', `El borrador de ${name} está bloqueado (placeholder sin resolver). No envié nada.`); + } + + // G-C: dedupe de doble tap. Si esta misma secuencia ya fue aprobada y su envío + // ya salió SIN fallar, un segundo tap de la tarjeta NO debe generar otro egress. + // Un envío fallido NO bloquea: el owner puede reintentar con la misma tarjeta. + if (pf.approval?.status === 'approved' && pf.send?.sent_at && String(pf.send.send_status || '').toLowerCase() !== 'failed' && !pf.send.error) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_dup', + body: `Ese opener ("${sequence.label}") ya se había enviado a ${name} (${pf.send.sent_at}). Detecté un doble tap y no envié nada nuevo.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // GUARDIA cliente_expansion (D7): la secuencia presupone un proyecto andando. + // Solo dispara si el contacto tiene un deal vivo (status='open') en P2 (IA360 + // WhatsApp Revenue Pipeline) o P7 (Champions). Sin deal vivo → bloquear con aviso. + // G-C: con try/catch — si la consulta falla, el owner se entera (nunca mudo) y + // NO se envía nada (fail-closed). + if (sequence.requiresLiveDeal) { + let liveRows; + try { + ({ rows: liveRows } = await pool.query( + `SELECT 1 + FROM coexistence.deals d + JOIN coexistence.pipelines p ON p.id = d.pipeline_id + WHERE p.name IN ('IA360 WhatsApp Revenue Pipeline', 'Champions — Adopción y expansión') + AND d.contact_wa_number = $1 + AND d.contact_number = $2 + AND d.status = 'open' + LIMIT 1`, + [record.wa_number, targetContact] + )); + } catch (liveErr) { + console.error('[ia360-approve] live deal check failed:', liveErr.message); + return deny('live_deal_check_failed', `No pude verificar si ${name} tiene un proyecto activo (error de base de datos). Por seguridad no envié nada; reintenta en un momento.`); + } + if (!liveRows.length) { + return deny('no_live_deal', `${name} no tiene un proyecto activo (deal vivo en P2/P7). La secuencia ${sequence.id} solo aplica a clientes con proyecto en curso; elige otra secuencia. No envié nada.`); + } + } + + // GATE DE SEGURIDAD: allowlist de prueba. Sin allowlist o fuera de ella → NO envía. + // '*' = la aprobación explícita del owner autoriza a cualquier contacto. + const allowRaw = String(process.env.IA360_APPROVE_SEND_ALLOWLIST || '').trim(); + const allow = ia360ApproveSendAllowlist(); + if (allowRaw !== '*' && (!allow.length || !allow.includes(normalizePhone(targetContact)))) { + return deny('not_in_test_allowlist', `Gate de seguridad: ${name} (${targetContact}) no está en IA360_APPROVE_SEND_ALLOWLIST. Aprobación registrada pero NO envié nada al contacto.`); + } + + // Ventana de servicio 24h: dentro → texto libre (el draft). Fuera → se requiere + // template aprobado por Meta (validado vía templateValidator en enqueueIa360Template); + // las secuencias persona-first aún no tienen template mapeado → bloquear con aviso. + const { account, error: accErr } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (accErr || !account) return deny('account_resolve_failed', 'No pude resolver la cuenta de WhatsApp. No envié nada.'); + const secs = await secondsSinceLastIncoming({ accountPhoneNumberId: account.phoneNumberId, contactNumber: targetContact }); + const insideWindow = secs != null && secs < 23.5 * 3600; + const targetRecord = { ...record, contact_number: targetContact, contact_name: name }; + let sendResult = { ok: false, status: 'not_sent', error: null }; + const openerLabel = `ia360_seq_opener_${sequence.id}`; + if (insideWindow) { + // Openers v2: dentro de ventana el opener sale como interactive (botones/lista) + // con el copy aprobado en el readout; secuencias sin openerOptions siguen en texto. + const openerInteractive = buildIa360OpenerInteractive({ sequence, bodyText: pf.sequence_candidate.draft }); + let sent; + let handlerFor; + if (openerInteractive) { + sent = await enqueueIa360Interactive({ + record: targetRecord, + label: openerLabel, + messageBody: `IA360 opener: ${sequence.label}`, + interactive: openerInteractive, + dedupSuffix: `:opener:${targetContact}`, + }); + handlerFor = `${record.message_id}:opener:${targetContact}`; + } else { + sent = await sendIa360DirectText({ + record, + toNumber: targetContact, + label: openerLabel, + body: pf.sequence_candidate.draft, + }); + handlerFor = `${record.message_id}:direct:${targetContact}`; + } + if (!sent) return deny('enqueue_failed', `No pude encolar el opener para ${name}. No se envió.`); + const status = await waitForIa360OutboundStatus(handlerFor); + sendResult = { ok: String(status?.status || '').toLowerCase() === 'sent', status: status?.status || 'unknown', error: status?.error_message || null, message_id: status?.message_id || null }; + } else if (sequence.metaTemplateName) { + const res = await enqueueIa360Template({ record: targetRecord, label: openerLabel, templateName: sequence.metaTemplateName }); + sendResult = { ok: res.ok, status: res.status, error: res.error || null, message_id: null }; + } else { + return deny('outside_window_no_template', `${name} está fuera de la ventana de 24h y la secuencia ${sequence.id} no tiene template aprobado por Meta. No envié nada.`); + } + + // Persistencia de la aprobación + resultado del envío. + const nowIso = new Date().toISOString(); + const pfUpdated = { + ...pf, + dry_run: false, + approval: { status: 'approved', approved_by: IA360_OWNER_NUMBER, approved_at: nowIso, reason: 'Aprobado por Alek desde la tarjeta de aprobación.' }, + guardrail: { ...(pf.guardrail || {}), current_block: 'none', external_send_allowed: true, allowed_recipient: targetContact }, + send: { + sent_at: nowIso, + send_status: sendResult.status, + send_mode: insideWindow ? 'text_inside_window' : 'template_outside_window', + outbound_message_id: sendResult.message_id || null, + error: sendResult.error || null, + }, + }; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-approve-send', `approved-seq:${sequence.id}`], + customFields: { + ia360_persona_first: pfUpdated, + approved_by: IA360_OWNER_NUMBER, + approved_at: nowIso, + sent_at: nowIso, + send_status: sendResult.status, + outbound_message_id: sendResult.message_id || null, + // G-C: un opener nuevo abre un ciclo nuevo — la respuesta del ciclo anterior + // no debe activar el dedupe del router seq_* (el contacto debe poder volver + // a elegir la misma opción y recibir su paso 2). + ia360_seq_last_response: null, + }, + }).catch(e => console.error('[ia360-approve] persist approval:', e.message)); + + if (!sendResult.ok) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_failed', + body: `Aprobado, pero el envío a ${name} quedó en estado "${sendResult.status}"${sendResult.error ? ' (' + sendResult.error + ')' : ''}. Revisa chat_history; no avancé el pipeline.`, + targetContact, + ownerBudget: true, + }); + return; + } + + // Avance del pipeline: el opener salió → "Diagnóstico enviado". + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Opener aprobado', + notes: `Opener de secuencia ${sequence.id} aprobado por Alek y enviado (${insideWindow ? 'texto, ventana abierta' : 'template'}). Stage → Diagnóstico enviado.`, + }).catch(e => console.error('[ia360-approve] syncIa360Deal:', e.message)); + + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_send_done', + body: `Listo. Envié el opener de "${sequence.label}" a ${name} (${targetContact}) y moví su deal a "Diagnóstico enviado".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360OwnerApproveManual({ record, targetContact }) { + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['ia360-tomar-manual'], + customFields: { ia360_owner_takeover_at: new Date().toISOString(), stage: 'Requiere Alek' }, + }).catch(e => console.error('[ia360-approve] manual persist:', e.message)); + await syncIa360Deal({ + record: { ...record, contact_number: targetContact, contact_name: name }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Tomado manual', + notes: 'Alek tomó el contacto manualmente desde la tarjeta de aprobación. Sin envío del bot.', + }).catch(e => console.error('[ia360-approve] manual deal:', e.message)); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_approve_manual_ack', + body: `Ok, tú le escribes a ${name}. No envié nada y moví su deal a "Requiere Alek".`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice }) { + const terminal = IA360_TERMINAL_VCARD_CHOICES[terminalChoice]; + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + if (!terminal) return false; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: terminal, + sequence: terminal.sequence, + ownerAction: terminalChoice === 'excluir' ? 'no_contactar_selected' : 'solo_guardar_selected', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: terminal, + sequence: terminal.sequence, + payload, + tags: terminalChoice === 'excluir' ? ['no-contactar'] : ['solo-guardar'], + }); + const readout = buildIa360SequenceReadout({ name, targetContact, flow: terminal, sequence: terminal.sequence, payload }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: `owner_terminal_${terminal.sequence.id}`, + body: readout, + targetContact, + ownerBudget: true, + }); + return true; +} + +async function handleIa360LegacyOwnerPipeChoice({ record, targetContact, choice, contact }) { + const name = contact?.name || targetContact; + const legacyFlow = { + personaContext: 'Requiere Alek', + relationshipContext: 'legacy_button_guard', + flywheelPhase: 'Unknown', + riskLevel: 'high', + notes: 'Botón heredado de pipeline o nutrición bloqueado por regla persona-first.', + }; + const legacySequence = { + id: `legacy_${String(choice || 'sin_ruta').replace(/[^a-z0-9_]+/g, '_')}`, + label: `Botón heredado: ${choice || 'sin ruta'}`, + goal: 'bloquear rutas antiguas hasta que Alek apruebe persona, secuencia y copy', + expectedSignal: 'Alek revisa manualmente si el botón antiguo debe rediseñarse', + nextAction: 'No usar el botón heredado. Reclasificar persona y elegir una secuencia persona-first.', + cta: 'bloquear botón heredado', + copyStatus: 'blocked', + draft: () => `No se genera borrador para ${name}; el botón heredado "${choice || 'sin ruta'}" queda bloqueado.`, + }; + const payload = buildIa360PersonaPayload({ + record, + contact, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + ownerAction: 'legacy_button_blocked', + }); + await persistIa360PersonaPayload({ + record, + targetContact, + flow: legacyFlow, + sequence: legacySequence, + payload, + tags: ['legacy-owner-pipe-blocked'], + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_pipe_legacy_blocked', + body: `Botón heredado bloqueado para ${name}: ${choice || 'sin ruta'}.\n\nEstado: Requiere Alek.\nNo envié nada al contacto. No creé oportunidad comercial. Reclasifica persona y elige una secuencia persona-first si quieres preparar borrador.`, + targetContact, + ownerBudget: true, + }); +} + +async function handleIa360SharedContacts(record) { + if (!record || record.direction !== 'incoming' || record.message_type !== 'contacts') return false; + try { + const sharedContacts = extractSharedContactsFromRecord(record); + if (!sharedContacts.length) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_parse_failed', + body: 'Recibí una tarjeta de contacto, pero no pude extraer un número de WhatsApp. Revísala manualmente.', + ownerBudget: true, + }); + return true; + } + + for (const shared of sharedContacts.slice(0, 5)) { + if (isIa360OwnerNumber(shared.contactNumber)) { + await recordBlockedOwnerNumberVcard({ record, shared }); + continue; + } + const saved = await upsertIa360SharedContact({ record, shared }); + console.log('[ia360-vcard] captured contact=%s name=%s source=%s', shared.contactNumber, shared.name || '-', record.contact_number || '-'); + const targetRecord = { + ...record, + contact_number: shared.contactNumber, + contact_name: shared.name, + message_type: 'contacts', + message_body: `Contacto compartido por WhatsApp: ${shared.name || shared.contactNumber}`, + }; + reflectIa360ToEspoCrm({ + record: targetRecord, + channel: 'whatsapp-vcard', + agent: { + intent: 'owner_shared_contact_capture', + action: 'stage_contact', + extracted: { + intake_source: 'b29-vcard-whatsapp', + staged: true, + contact_id: saved?.id || null, + referred_by: record.contact_number || null, + }, + }, + }).catch(e => console.error('[ia360-vcard] crm reflect:', e.message)); + await notifyOwnerVcardCaptured({ record, shared }); + } + return true; + } catch (err) { + console.error('[ia360-vcard] handler error:', err.message); + return false; + } +} + +async function loadIa360ContactForOwnerAction({ waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT id, COALESCE(name, profile_name, $2) AS name, custom_fields + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [waNumber, contactNumber] + ); + return rows[0] || null; +} + +async function sendOwnerPipelineSlots({ record }) { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + if (!url) return false; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'forgechat-ia360-owner-pipe', + nextAvailable: true, + workStartHour: 10, + workEndHour: 18, + slotMinutes: 60, + contact: { + waNumber: record.wa_number, + contactNumber: record.contact_number, + contactName: record.contact_name || null, + }, + }), + }).catch(e => { + console.error('[ia360-owner-pipe] availability request failed:', e.message); + return null; + }); + if (!res || !res.ok) { + console.error('[ia360-owner-pipe] availability failed:', res && res.status); + return false; + } + const data = await res.json().catch(() => null); + const slots = (data && Array.isArray(data.slots)) ? data.slots : []; + if (!slots.length) { + await enqueueIa360Text({ + record, + label: 'owner_pipe_no_slots', + body: 'Alek me pidió agendar contigo, pero no encontré espacios libres en los próximos días. Te escribo en cuanto confirme opciones.', + }); + return true; + } + await enqueueIa360Interactive({ + record, + label: 'owner_pipe_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Alek me pidió pasarte opciones para revisar tu caso. Estos espacios de 1 hora están libres (hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map(slot => ({ + id: slot.id, + title: String(slot.title || '').slice(0, 24), + description: slot.description || '', + })), + }], + }, + }, + }); + return true; +} + +async function handleIa360OwnerPipelineChoice({ record, targetContact, pipeline }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_pipe_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const ctx = await validateIa360OwnerContext({ + record, + targetContact, + action: 'owner_pipe', + expectedLabelPrefix: `owner_vcard_captured_${targetContact}`, + }); + if (!ctx.ok) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: ctx.reason, + expectedLabel: `owner_vcard_captured_${targetContact}`, + }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const choice = String(pipeline || '').toLowerCase(); + const qaExpectedChoice = contact?.custom_fields?.qa_persona_expected_choice || null; + if (qaExpectedChoice && choice !== qaExpectedChoice) { + await blockIa360OwnerContextMismatch({ + record, + targetContact, + action: 'owner_pipe', + reason: `qa_persona_hint_mismatch:${choice || 'empty'}`, + expectedLabel: `qa_expected=${qaExpectedChoice}`, + }); + return; + } + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_pipe', + message_body: `Alek eligió pipeline ${choice}`, + }; + + if (IA360_PERSONA_SEQUENCE_FLOWS[choice]) { + await handleIa360PersonaChoice({ record, targetContact, personaChoice: choice }); + return; + } + + if (IA360_TERMINAL_VCARD_CHOICES[choice]) { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: choice }); + return; + } + + await handleIa360LegacyOwnerPipeChoice({ record: targetRecord, targetContact, choice, contact }); +} + +async function handleIa360OwnerVcardAction({ record, ownerAction, targetContact }) { + if (!targetContact) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_vcard_missing_target', body: 'No encontré el número del contacto de esa acción.' }); + return; + } + const contact = await loadIa360ContactForOwnerAction({ waNumber: record.wa_number, contactNumber: targetContact }); + const name = contact?.name || targetContact; + const targetRecord = { + ...record, + contact_number: targetContact, + contact_name: name, + message_type: 'owner_action', + message_body: `Acción owner sobre vCard: ${ownerAction}`, + }; + + if (ownerAction === 'owner_vcard_pipe') { + await notifyOwnerVcardCaptured({ + record, + shared: { name, contactNumber: targetContact }, + }); + return; + } + + if (ownerAction === 'owner_vcard_take') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['requiere-alek', 'owner-manual'], + customFields: { + staged: false, + stage: 'Requiere Alek', + owner_action: 'manual_take', + owner_action_at: new Date().toISOString(), + }, + }); + await syncIa360Deal({ + record: targetRecord, + targetStageName: 'Requiere Alek', + titleSuffix: 'vCard', + notes: 'Alek tomo manualmente este contacto compartido por vCard.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_take_ack', + body: `Ok, ${name} quedó en Requiere Alek para que lo tomes manualmente. No le envié mensaje.`, + }); + return; + } + + if (ownerAction === 'owner_vcard_keep') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: targetContact, + tags: ['solo-guardar'], + customFields: { + staged: true, + stage: 'Capturado / Por rutear', + owner_action: 'solo_guardar', + owner_action_at: new Date().toISOString(), + }, + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_vcard_keep_ack', + body: `Guardado: ${name} (${targetContact}) queda capturado sin pipeline ni envío.`, + }); + } +} + +const IA360_OWNER_NUMBER = '5213322638033'; + +function parsePositiveIntEnv(name, fallback, min = 1) { + const value = parseInt(process.env[name] || '', 10); + return Number.isFinite(value) && value >= min ? value : fallback; +} + +const IA360_OWNER_NOTIFY_WINDOW_SECONDS = parsePositiveIntEnv('IA360_OWNER_NOTIFY_WINDOW_SECONDS', 60, 10); +const IA360_OWNER_NOTIFY_MAX_PER_WINDOW = parsePositiveIntEnv('IA360_OWNER_NOTIFY_MAX_PER_WINDOW', 6, 1); + +function inferIa360OwnerNotifyTarget(label) { + const m = String(label || '').match(/(?:owner_vcard_captured|owner_sequence_selector)_(\d+)/); + return m ? m[1] : null; +} + +async function recordIa360OwnerNotifySuppressed({ record, label, targetContact, reason }) { + const contactNumber = targetContact || inferIa360OwnerNotifyTarget(label); + console.warn('[ia360-owner] suppressed notify label=%s target=%s reason=%s', label || '-', contactNumber || '-', reason || '-'); + if (!contactNumber || !record?.wa_number) return; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber, + customFields: { + ia360_owner_notify_suppressed_at: new Date().toISOString(), + ia360_owner_notify_suppressed_label: label || '', + ia360_owner_notify_suppressed_reason: reason || '', + ia360_owner_notify_window_seconds: IA360_OWNER_NOTIFY_WINDOW_SECONDS, + ia360_owner_notify_max_per_window: IA360_OWNER_NOTIFY_MAX_PER_WINDOW, + }, + }).catch(e => console.error('[ia360-owner] suppress persist:', e.message)); +} + +async function canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }) { + if (!ownerBudget) return true; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count + FROM coexistence.chat_history + WHERE wa_number=$1 + AND contact_number=$2 + AND direction='outgoing' + AND template_meta->>'ux'='ia360_owner' + AND created_at > NOW() - ($3::int * INTERVAL '1 second')`, + [record.wa_number, IA360_OWNER_NUMBER, IA360_OWNER_NOTIFY_WINDOW_SECONDS] + ); + const count = rows[0]?.count || 0; + if (count < IA360_OWNER_NOTIFY_MAX_PER_WINDOW) return true; + await recordIa360OwnerNotifySuppressed({ + record, + label, + targetContact, + reason: `owner_notify_budget_exceeded:${count}/${IA360_OWNER_NOTIFY_MAX_PER_WINDOW}/${IA360_OWNER_NOTIFY_WINDOW_SECONDS}s`, + }); + return false; +} + +// Envia un interactivo al OWNER (Alek), no al record.contact_number. Construye la +// fila + encola apuntando a IA360_OWNER_NUMBER. NO pasa por resolveIa360Outbound +// (su dedup es por contact_number+ia360_handler_for; aqui el destino es otro +// numero, no colisiona). try/catch propio: nunca tumba el webhook. +async function sendOwnerInteractive({ record, interactive, label, messageBody, targetContact = null, ownerBudget = false }) { + try { + if (!(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber: IA360_OWNER_NUMBER, + messageType: 'interactive', + messageBody: messageBody || 'IA360 owner', + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:owner:${label}`, source: 'webhook_owner_notify' }, + }); + await enqueueSend({ kind: 'interactive', accountId: account.id, to: IA360_OWNER_NUMBER, localMessageId: localId, payload: { interactive } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendOwnerInteractive error:', err.message); + return false; + } +} + +// Envia texto libre a un numero ARBITRARIO (p.ej. el contacto cuya cita se cancela +// desde la rama owner, donde record.contact_number es Alek, no el prospecto). +async function sendIa360DirectText({ record, toNumber, body, label, targetContact = null, ownerBudget = false }) { + try { + if (normalizePhone(toNumber) === IA360_OWNER_NUMBER && + !(await canEnqueueIa360OwnerNotify({ record, label, targetContact, ownerBudget }))) return false; + const { account, error } = await resolveAccount({ fromPhoneNumber: record.wa_number }); + if (error || !account) { console.error('[ia360-owner] direct text account resolve failed:', error || 'unknown'); return false; } + const localId = await insertPendingRow({ + account, + toNumber, + messageType: 'text', + messageBody: body, + templateMeta: { ux: 'ia360_owner', label, ia360_handler_for: `${record.message_id}:direct:${toNumber}`, source: 'webhook_owner_direct' }, + }); + await enqueueSend({ kind: 'text', accountId: account.id, to: toNumber, localMessageId: localId, payload: { body, previewUrl: false } }); + return true; + } catch (err) { + console.error('[ia360-owner] sendIa360DirectText error:', err.message); + return false; + } +} + +// ─── Expediente del owner: "qué sabes de " ────────────────── +// Comando read-only del owner: arma un expediente con los facts y eventos de +// coexistence.ia360_memory_* para un contacto, resuelto por número o por +// nombre (tolerante a acentos y a typos simples tipo Emmanuel/Emanuel). +// Egress SOLO vía sendIa360DirectText; nunca escribe memoria y SIEMPRE +// responde algo (sin expediente / candidatos / error), nunca queda mudo. +const IA360_BOT_WA_NUMBER = '5213321594582'; // número del bot: jamás es contacto + +function parseIa360OwnerMemoryQuery(body) { + const text = String(body || '').trim(); + const m = text.match(/^¿?\s*(?:qu[eé]|qui[eé]n)\s+sabes\s+(?:de\s+la|de\s+el|del|de|sobre)\s+(.+?)\s*\?*$/i); + if (!m) return null; + const q = m[1].trim(); + return q || null; +} + +// Normaliza para comparar nombres: minúsculas, sin acentos y con letras +// repetidas colapsadas ("Emmanuel" y "Emanuel" → "emanuel"). +function ia360NormalizeNameForMatch(s) { + return String(s || '') + .toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .replace(/(.)\1+/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +async function resolveIa360MemoryTarget(query) { + const digits = String(query || '').replace(/\D/g, ''); + if (digits.length >= 10) { + // Número directo: 10 dígitos MX → prefijo 521 (formato ForgeChat). + const number = digits.length === 10 ? `521${digits}` : digits; + return { kind: 'number', candidates: [{ contact_number: number, contact_name: null }] }; + } + const { rows } = await pool.query( + `SELECT DISTINCT contact_number, contact_name FROM ( + SELECT contact_number, contact_name + FROM coexistence.ia360_memory_events + WHERE contact_name IS NOT NULL AND contact_number IS NOT NULL + UNION ALL + SELECT contact_number, COALESCE(name, profile_name) AS contact_name + FROM coexistence.contacts + WHERE COALESCE(name, profile_name) IS NOT NULL AND contact_number IS NOT NULL + ) t + WHERE contact_number <> $1`, + [IA360_BOT_WA_NUMBER] + ); + const needle = ia360NormalizeNameForMatch(query); + if (!needle) return { kind: 'none', candidates: [] }; + const byNumber = new Map(); + for (const r of rows) { + if (!ia360NormalizeNameForMatch(r.contact_name).includes(needle)) continue; + if (!byNumber.has(r.contact_number)) byNumber.set(r.contact_number, r); + } + const candidates = [...byNumber.values()]; + if (!candidates.length) return { kind: 'none', candidates: [] }; + if (candidates.length > 1) return { kind: 'ambiguous', candidates }; + return { kind: 'name', candidates }; +} + +async function buildIa360ContactDossier(contactNumber) { + const num = normalizePhone(contactNumber); + const { rows: factRows } = await pool.query( + `SELECT project_name, persona, role, account_name, preference, objection, + recurring_pain, affected_process, missing_metric + FROM coexistence.ia360_memory_facts + WHERE contact_number = $1 + ORDER BY last_seen_at DESC, id DESC`, + [num] + ); + const { rows: eventRows } = await pool.query( + `SELECT contact_name, area, signal_type, summary + FROM coexistence.ia360_memory_events + WHERE contact_number = $1 + ORDER BY created_at DESC, id DESC + LIMIT 12`, + [num] + ); + if (!factRows.length && !eventRows.length) return null; + + const name = eventRows.find(e => e.contact_name)?.contact_name || null; + const header = [`Expediente IA360: ${name || 'contacto'} (${num})`]; + const meta = []; + const accountRow = factRows.find(f => f.account_name); + const projectRow = factRows.find(f => f.project_name); + const personaRow = factRows.find(f => f.persona); + if (accountRow) meta.push(`Cuenta: ${accountRow.account_name}`); + if (projectRow) meta.push(`Proyecto: ${projectRow.project_name}`); + if (personaRow) meta.push(`Persona: ${personaRow.persona}`); + if (meta.length) header.push(meta.join(' · ')); + + // Los facts viven duplicados por el doble keying de contact_wa_number + // (monolito vs lookup v2): dedupe por contenido, no por fila. + const factLines = []; + const seenFacts = new Set(); + for (const f of factRows) { + for (const field of ['preference', 'objection', 'recurring_pain', 'affected_process', 'missing_metric']) { + const val = String(f[field] || '').trim(); + if (!val) continue; + const key = `${field}:${val}`; + if (seenFacts.has(key)) continue; + seenFacts.add(key); + factLines.push(`- ${val.length > 300 ? `${val.slice(0, 297)}...` : val}`); + } + } + const eventLines = []; + const seenEvents = new Set(); + for (const e of eventRows) { + const val = String(e.summary || '').trim(); + if (!val) continue; + const key = `${e.area}|${e.signal_type}|${val}`; + if (seenEvents.has(key)) continue; + seenEvents.add(key); + eventLines.push(`- [${e.area}/${e.signal_type}] ${val.length > 220 ? `${val.slice(0, 217)}...` : val}`); + } + + const lines = [...header, '']; + if (factLines.length) lines.push(`Facts (${factLines.length}):`, ...factLines, ''); + if (eventLines.length) lines.push(`Eventos recientes (${eventLines.length}):`, ...eventLines); + let body = lines.join('\n').trim(); + // Límite duro de WhatsApp: 4096 chars por texto. + if (body.length > 3900) body = `${body.slice(0, 3880)}\n... (recortado)`; + return body; +} + +async function handleIa360OwnerMemoryQuery({ record, query }) { + let body; + try { + const target = await resolveIa360MemoryTarget(query); + if (target.kind === 'none') { + body = `Sin expediente: no encontré facts ni eventos para "${query}". Revisa el nombre o mándame el número completo.`; + } else if (target.kind === 'ambiguous') { + const list = target.candidates.slice(0, 8) + .map(c => `- ${c.contact_name || 'sin nombre'} (${c.contact_number})`).join('\n'); + body = `Encontré varios contactos que coinciden con "${query}". ¿De cuál quieres el expediente?\n${list}\n\nMándame "qué sabes de " para verlo.`; + } else { + const dossier = await buildIa360ContactDossier(target.candidates[0].contact_number); + body = dossier + || `Sin expediente: el contacto ${target.candidates[0].contact_number} no tiene facts ni eventos guardados todavía.`; + } + } catch (err) { + console.error('[ia360-expediente] dossier error:', err.message); + body = `No pude leer el expediente de "${query}" ahora mismo (error interno). Inténtalo de nuevo en un momento.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_memory_dossier', body }); +} + +// ─── Bandeja de ideas del owner ───────────────────────────────────────────── +// Una idea (comando del owner "idea: ", detección en conversación vía +// Brain v2, o un agente) se persiste en coexistence.ia360_ideas y genera una +// tarjeta de ruteo al owner con 4 destinos. Reusa el patrón tarjeta-aprobación +// (sendOwnerInteractive + handler owner_*). Las tarjetas van SOLO al owner. +const IA360_IDEAS_STATUS_BY_ACTION = { + owner_idea_prod: 'routed_production', + owner_idea_docs: 'routed_docs', + owner_idea_crm: 'routed_crm', + owner_idea_reject: 'rejected', +}; + +async function insertIa360Idea({ fuente, contactNumber, texto, contexto }) { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_ideas (fuente, contact_number, texto, contexto_json) + VALUES ($1,$2,$3,$4::jsonb) RETURNING id`, + [fuente, contactNumber || null, texto, JSON.stringify(contexto || {})] + ); + return rows[0].id; +} + +async function sendIa360IdeaCard({ record, ideaId, texto, fuente, contactNumber = null }) { + const origen = fuente === 'owner' ? 'tuya' : `de la conversación con ${contactNumber || 'un contacto'}`; + const preview = texto.length > 480 ? `${texto.slice(0, 477)}...` : texto; + return sendOwnerInteractive({ + record, + label: `owner_idea_card_${ideaId}`, + messageBody: `IA360: idea #${ideaId} capturada`, + ownerBudget: true, + interactive: { + type: 'list', + header: { type: 'text', text: `Idea #${ideaId} capturada` }, + body: { text: `Alek, capturé esta idea (${origen}):\n\n"${preview}"\n\n¿A dónde la ruteo?` }, + footer: { text: 'Bandeja de ideas · IA360' }, + action: { + button: 'Rutear', + sections: [{ + title: 'Destinos', + rows: [ + { id: `owner_idea_prod:${ideaId}`, title: 'Producción', description: 'Backlog de producción (routed_production)' }, + { id: `owner_idea_docs:${ideaId}`, title: 'Documentar', description: 'Encolar al vault local AlekContenido (ia360_docs_sync)' }, + { id: `owner_idea_crm:${ideaId}`, title: 'CRM', description: 'Crear nota en EspoCRM ligada al contacto' }, + { id: `owner_idea_reject:${ideaId}`, title: 'Rechazar', description: 'Descartar; puedes responder con el motivo' }, + ], + }], + }, + }, + }); +} + +async function handleIa360OwnerIdeaCommand({ record, texto }) { + const ideaId = await insertIa360Idea({ + fuente: 'owner', + contactNumber: IA360_OWNER_NUMBER, + texto, + contexto: { source: 'owner_command', message_id: record.message_id, captured_at: new Date().toISOString() }, + }); + const sent = await sendIa360IdeaCard({ record, ideaId, texto, fuente: 'owner' }); + if (!sent) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_card_fail', body: `Idea #${ideaId} guardada, pero no pude mandar la tarjeta; queda pending en la bandeja.`, ownerBudget: true }); + } +} + +async function handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId }) { + const status = IA360_IDEAS_STATUS_BY_ACTION[ownerAction]; + const idNum = String(ideaId || '').replace(/\D/g, ''); + if (!status || !idNum) return; + const { rows } = await pool.query( + `UPDATE coexistence.ia360_ideas + SET status=$1, routed_at=now(), approved_by=$2 + WHERE id=$3 AND status='pending' + RETURNING id, fuente, contact_number, texto, contexto_json`, + [status, IA360_OWNER_NUMBER, idNum] + ); + if (!rows.length) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'idea_route_dup', body: `La idea #${idNum} ya estaba ruteada (o no existe). No hice cambios.`, ownerBudget: true }); + return; + } + const idea = rows[0]; + let ack; + if (status === 'routed_production') { + ack = `Idea #${idea.id} marcada para PRODUCCIÓN (routed_production). Queda en la bandeja para la siguiente ventana de implementación.`; + } else if (status === 'routed_docs') { + const titulo = idea.texto.length > 80 ? `${idea.texto.slice(0, 77)}...` : idea.texto; + const contenido = `# Idea #${idea.id}\n\n- Fuente: ${idea.fuente}\n- Contacto: ${idea.contact_number || '-'}\n- Capturada: ${new Date().toISOString()}\n\n${idea.texto}\n\nContexto: ${JSON.stringify(idea.contexto_json || {})}`; + await pool.query( + `INSERT INTO coexistence.ia360_docs_sync (idea_id, titulo, contenido, destino) VALUES ($1,$2,$3,'AlekContenido')`, + [idea.id, titulo, contenido] + ); + ack = `Idea #${idea.id} encolada para DOCUMENTAR (ia360_docs_sync, destino AlekContenido). La ventana local drena la cola al vault.`; + } else if (status === 'routed_crm') { + const identifier = idea.fuente === 'owner' ? IA360_OWNER_NUMBER : (idea.contact_number || IA360_OWNER_NUMBER); + let espoOk = false; + try { + const { rows: cRows } = await pool.query( + `SELECT custom_fields->>'espo_id' AS espo_id, COALESCE(name, profile_name) AS name + FROM coexistence.contacts WHERE contact_number=$1 ORDER BY updated_at DESC LIMIT 1`, + [identifier] + ); + const res = await fetch(N8N_IA360_UPSERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channel: 'whatsapp', + identifier, + espo_id: cRows[0]?.espo_id || null, + name: cRows[0]?.name || null, + intent: 'idea_captura', + action: 'idea_routed_crm', + extracted: { idea_id: idea.id, fuente: idea.fuente }, + last_message: `[IDEA #${idea.id}] ${idea.texto}`, + transcript_stored: false, + }), + }); + espoOk = res.ok; + } catch (e) { + console.error('[ia360-ideas] espo route error:', e.message); + } + ack = espoOk + ? `Idea #${idea.id} reflejada en EspoCRM como nota del contacto ${identifier} (routed_crm).` + : `Idea #${idea.id} quedó routed_crm, pero el upsert a EspoCRM falló; revisa el workflow n8n.`; + } else { + ack = `Idea #${idea.id} RECHAZADA. Si quieres, responde con el motivo y lo dejamos registrado.`; + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: `idea_route_${status}`, body: ack, ownerBudget: true }); +} + +// POST al workflow n8n de cancelar (borra Calendar + Zoom). Requiere User-Agent +// tipo Chrome por Cloudflare (los otros helpers n8n no lo necesitan; este SI). +async function cancelIa360Booking({ calendarEventId, zoomMeetingId }) { + const url = process.env.N8N_IA360_CANCEL_WEBHOOK_URL || 'https://n8n.geekstudio.dev/webhook/ia360-calendar-cancel'; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ calendarEventId: calendarEventId || '', zoomMeetingId: zoomMeetingId || '' }), + }); + const text = await res.text().catch(() => ''); + if (!res.ok) { console.error('[ia360-cancel] webhook failed:', res.status, text); return { ok: false, status: res.status }; } + return { ok: true, body: text }; + } catch (err) { + console.error('[ia360-cancel] webhook error:', err.message); + return { ok: false, error: err.message }; + } +} + +// ── MULTI-CITA: helpers de lista de reservas ───────────────────────────────── +// Un contacto puede tener VARIAS reuniones. La fuente de verdad es el customField +// `ia360_bookings` = array JSON de {start, event_id, zoom_id}. Los campos sueltos +// (ia360_booking_event_id/zoom_id/start) se conservan por compat pero ya NO mandan. + +// Lee el array de reservas de un contacto. Si `ia360_bookings` esta vacio pero hay +// campos sueltos legacy (una cita previa al multi-cita), sintetiza un array de 1 +// elemento para que el cancelar siga funcionando sobre data preexistente. +async function loadIa360Bookings(contactNumber) { + try { + const { rows } = await pool.query( + `SELECT custom_fields->'ia360_bookings' AS arr, + custom_fields->>'ia360_booking_event_id' AS evt, + custom_fields->>'ia360_booking_zoom_id' AS zoom, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts WHERE contact_number=$1 + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [contactNumber] + ); + if (!rows.length) return []; + let arr = rows[0].arr; + if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch (_) { arr = null; } } + if (Array.isArray(arr) && arr.length) { + return arr.filter(b => b && (b.event_id || b.zoom_id)); + } + // Fallback legacy: una sola cita en campos sueltos. + if (rows[0].evt || rows[0].zoom) { + return [{ start: rows[0].start || '', event_id: rows[0].evt || '', zoom_id: rows[0].zoom || '' }]; + } + return []; + } catch (err) { + console.error('[ia360-multicita] loadIa360Bookings error:', err.message); + return []; + } +} + +// Gap#5: list_bookings debe reflejar la REALIDAD aunque el cache `ia360_bookings` se +// haya desincronizado (append fallido, edición directa en Calendar, limpieza). Unimos +// el cache con las citas FUTURAS registradas en `ia360_meeting_links` (fuente durable +// escrita al agendar), dedup por event_id. El cancel borra de AMBOS (removeIa360Booking), +// así que una cita cancelada NO reaparece aquí. Solo se usa para LISTAR (read-only); el +// cancel/append/find siguen usando loadIa360Bookings (que conserva zoom_id). +async function loadIa360BookingsForList(contactNumber) { + const cached = await loadIa360Bookings(contactNumber); + try { + const { rows } = await pool.query( + `SELECT event_id, start_utc + FROM coexistence.ia360_meeting_links + WHERE contact_number = $1 AND kind = 'cal' AND start_utc > now() + ORDER BY start_utc ASC`, + [contactNumber] + ); + const seen = new Set(cached.map(b => String(b.event_id || '').toLowerCase()).filter(Boolean)); + const merged = cached.slice(); + for (const r of rows) { + const eid = String(r.event_id || '').toLowerCase(); + if (!eid || seen.has(eid)) continue; + seen.add(eid); + merged.push({ start: r.start_utc ? new Date(r.start_utc).toISOString() : '', event_id: r.event_id || '', zoom_id: '' }); + } + merged.sort((a, b) => String(a.start || '').localeCompare(String(b.start || ''))); + if (merged.length !== cached.length) { + console.log('[ia360-list] reconciled contact=%s cache=%d -> merged=%d (meeting_links)', contactNumber, cached.length, merged.length); + } + return merged; + } catch (err) { + console.error('[ia360-multicita] loadIa360BookingsForList error:', err.message); + return cached; + } +} + +// Append idempotente: agrega una cita al array `ia360_bookings` (read-modify-write). +// Dedup por event_id (case-insensitive). Conserva los campos sueltos por compat. +async function appendIa360Booking({ waNumber, contactNumber, booking }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(booking.event_id || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid || !eid); + next.push({ start: booking.start || '', event_id: booking.event_id || '', zoom_id: booking.zoom_id || '' }); + console.log('[ia360-multicita] append event=%s -> %d cita(s)', booking.event_id || '-', next.length); + await mergeContactIa360State({ + waNumber, + contactNumber, + customFields: { ia360_bookings: next }, + }); + return next; +} + +// Formato corto CDMX para filas/copy: "Jue 4 jun 5:00pm". +function fmtIa360Short(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'short' }).format(d).replace('.', ''); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase().replace(/\s/g, ''); + if (time.endsWith(':00am') || time.endsWith(':00pm')) time = time.replace(':00', ''); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon} ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Formato medio CDMX (para confirmaciones largas). +function fmtIa360Medium(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + return new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(startRaw)); + } catch (_) { return 'la fecha agendada'; } +} + +// Fecha LOCAL CDMX (YYYY-MM-DD) de un start UTC, para filtrar por dia. NO comparar +// el ISO UTC directo: 2026-06-05T01:00Z es 4-jun en CDMX. +function ymdIa360CDMX(startRaw) { + if (!startRaw) return ''; + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Mexico_City', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(startRaw)); + } catch (_) { return ''; } +} + +// Nombra un dia (recibido como 'YYYY-MM-DD', p.ej. el date pedido por el prospecto) +// en CDMX con dia de semana + dia + mes: "miércoles 10 de junio". OJO: un +// 'YYYY-MM-DD' pelon se parsea como medianoche UTC; en CDMX (UTC-6/-5) eso "retrocede" +// al dia anterior. Anclamos a mediodia UTC (12:00Z) para que el dia de calendario +// quede fijo sin importar el offset. Devuelve '' si la fecha es invalida. +function fmtIa360DiaPedido(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(String(ymd))) return ''; + try { + const d = new Date(String(ymd) + 'T12:00:00Z'); + const s = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long', day: 'numeric', month: 'long' }).format(d); + // es-MX devuelve "miércoles, 10 de junio" → quitamos la coma tras el dia de semana. + return s.replace(',', ''); + } catch (_) { return ''; } +} + +// Formato largo CDMX para LISTAR reuniones del contacto: "Jueves 9 jun, 10:00 a.m.". +// Dia de semana COMPLETO + dia + mes corto + hora con a.m./p.m. (estilo MX). Usa el +// start UTC real de la cita (no un YYYY-MM-DD), asi que NO necesita el ancla de mediodia. +function fmtIa360Listado(startRaw) { + if (!startRaw) return 'la fecha agendada'; + try { + const d = new Date(startRaw); + const wd = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', weekday: 'long' }).format(d); + const day = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', day: 'numeric' }).format(d); + const mon = new Intl.DateTimeFormat('es-MX', { timeZone: 'America/Mexico_City', month: 'short' }).format(d).replace('.', ''); + let time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', hour: 'numeric', minute: '2-digit', hour12: true }).format(d).toLowerCase(); + time = time.replace(/\s*am$/, ' a.m.').replace(/\s*pm$/, ' p.m.'); + const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + return `${cap(wd)} ${day} ${mon}, ${time}`; + } catch (_) { return 'la fecha agendada'; } +} + +// Notifica al OWNER (Alek) para aprobar la cancelacion de UNA cita concreta. El +// boton "Aprobar" lleva el event_id de ESA cita (owner_cancel_yes:) para +// que la rama owner cancele exactamente esa. "Llamarlo"/"Mantener" llevan el numero +// del contacto (no necesitan event_id). El copy incluye la fecha/hora CDMX de la cita. +async function notifyOwnerCancelForBooking({ record, contactNumber, booking }) { + const startFmt = fmtIa360Medium(booking.start); + return sendOwnerInteractive({ + record, + label: 'owner_cancel_request', + messageBody: `IA360: ${contactNumber} pidió cancelar`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Cancelación solicitada' }, + body: { text: `Alek, un contacto (${contactNumber}) pidió cancelar su reunión del ${startFmt} (CDMX). ¿Qué hago?` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_cancel_yes:${booking.event_id}`, title: 'Aprobar' } }, + { type: 'reply', reply: { id: `owner_cancel_call:${contactNumber}`, title: 'Llamarlo' } }, + { type: 'reply', reply: { id: `owner_cancel_keep:${contactNumber}`, title: 'Mantener' } }, + ] }, + }, + }); +} + +// Resuelve una cita por event_id (case-insensitive) a traves de TODOS los contactos. +// En la rama owner, `record` es Alek; el boton solo trae el event_id, asi que este +// lateral join nos da el contacto duenno + los datos de la cita de un tiro. +async function findBookingByEventId(eventId) { + if (!eventId) return null; + try { + const { rows } = await pool.query( + `SELECT c.contact_number AS contact_number, + c.wa_number AS wa_number, + elem->>'event_id' AS event_id, + elem->>'zoom_id' AS zoom_id, + elem->>'start' AS start + FROM coexistence.contacts c, + LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(c.custom_fields->'ia360_bookings')='array' + THEN c.custom_fields->'ia360_bookings' ELSE '[]'::jsonb END + ) elem + WHERE lower(elem->>'event_id') = lower($1) + ORDER BY c.updated_at DESC NULLS LAST + LIMIT 1`, + [eventId] + ); + if (rows.length) return rows[0]; + // Fallback legacy: cita en campos sueltos (sin array). + const { rows: legacy } = await pool.query( + `SELECT contact_number, wa_number, + custom_fields->>'ia360_booking_event_id' AS event_id, + custom_fields->>'ia360_booking_zoom_id' AS zoom_id, + custom_fields->>'ia360_booking_start' AS start + FROM coexistence.contacts + WHERE lower(custom_fields->>'ia360_booking_event_id') = lower($1) + ORDER BY updated_at DESC NULLS LAST LIMIT 1`, + [eventId] + ); + return legacy[0] || null; + } catch (err) { + console.error('[ia360-multicita] findBookingByEventId error:', err.message); + return null; + } +} + +// Quita una cita (por event_id, case-insensitive) del array `ia360_bookings` de un +// contacto y reescribe el array. Devuelve el array resultante (puede quedar vacio). +async function removeIa360Booking({ waNumber, contactNumber, eventId }) { + const current = await loadIa360Bookings(contactNumber); + const eid = String(eventId || '').toLowerCase(); + const next = current.filter(b => String(b.event_id || '').toLowerCase() !== eid); + console.log('[ia360-multicita] remove event=%s -> %d cita(s)', String(eventId || '-'), next.length); + const extra = { ia360_bookings: next }; + // Si la cita removida era la "ultima" reflejada en campos sueltos, limpialos. + extra.ia360_booking_event_id = next.length ? (next[next.length - 1].event_id || '') : ''; + extra.ia360_booking_zoom_id = next.length ? (next[next.length - 1].zoom_id || '') : ''; + extra.ia360_booking_start = next.length ? (next[next.length - 1].start || '') : ''; + // Gap#5: mantener `ia360_meeting_links` en sync para que list_bookings (que también + // lee de ahí) NO resucite una cita cancelada (la tabla no tiene columna status). + try { + await pool.query(`DELETE FROM coexistence.ia360_meeting_links WHERE lower(event_id) = lower($1)`, [String(eventId || '')]); + } catch (e) { console.error('[ia360-multicita] meeting_links cleanup error:', e.message); } + await mergeContactIa360State({ waNumber, contactNumber, customFields: extra }); + return next; +} + +// Mensaje "pasivo": cortesías/cierres que NO ameritan fallback ni alerta al owner +// (responderlos con "déjame revisarlo con Alek" sería ruido). Si un mensaje pasivo +// no se respondió, se deja pasar en silencio (es lo correcto). +function isIa360PassiveMessage(body) { + const t = String(body || '').trim(); + if (!t) return true; + return /^(gracias|ok|okay|va|perfecto|listo|nos vemos|de acuerdo|sale|vale)\b/i.test(t); +} + +// ── PRODUCTION-HARDENING: fallback universal + log + alerta al owner ────────── +// Se dispara cuando un mensaje ACCIONABLE (dentro del embudo IA360) no se resolvió, +// o cuando una excepción tumbó el handler. Hace 3 cosas, todo defensivo (un fallo +// aquí NUNCA debe re-lanzar ni dejar al contacto en silencio): +// 1) INSERT una fila en coexistence.ia360_bot_failures (status 'abierto') → id. +// 2) Manda al CONTACTO el fallback "Recibí tu mensaje…" (si no se le respondió ya). +// 3) ALERTA al owner (Alek, free-form) con 3 botones: Lo tomo / Comentar / Ignorar, +// cada uno cargando el de la fila para cerrar el loop después. +// `reason` = 'no-manejado' (accionable sin resolver) o 'error: ' (excepción). +// `alreadyResponded` evita doble-texto si el flujo ya le dijo algo al contacto. +async function handleIa360BotFailure({ record, reason, alreadyResponded = false }) { + const fallbackBody = 'Recibí tu mensaje. Déjame revisarlo con Alek y te contacto en breve.'; + let failureId = null; + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.ia360_bot_failures + (contact_number, contact_message, reason, bot_fallback, status) + VALUES ($1, $2, $3, $4, 'abierto') + RETURNING id`, + [record.contact_number || null, record.message_body || null, reason || 'no-manejado', fallbackBody] + ); + failureId = rows[0]?.id || null; + } catch (dbErr) { + console.error('[ia360-failure] insert error:', dbErr.message); + } + // 2) Fallback al contacto (nunca silencio). Solo si no se le respondió ya. + if (!alreadyResponded) { + try { + await enqueueIa360Text({ record, label: 'ia360_fallback_no_silence', body: fallbackBody }); + } catch (sendErr) { + console.error('[ia360-failure] contact fallback send error:', sendErr.message); + } + } + // 3) Alerta al owner con botones para cerrar el loop (solo si tenemos id). + if (failureId != null) { + try { + const reasonShort = String(reason || 'no-manejado').slice(0, 80); + await sendOwnerInteractive({ + record, + label: 'owner_bot_failure', + messageBody: `IA360: el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}`, + interactive: { + type: 'button', + body: { text: `Alek, el bot no resolvió un mensaje de ${record.contact_number || 'desconocido'}: "${String(record.message_body || '').slice(0, 300)}" (${reasonShort}). ¿Qué hago?` }, + action: { + buttons: [ + { type: 'reply', reply: { id: `owner_take_fail:${failureId}`, title: 'Lo tomo' } }, + { type: 'reply', reply: { id: `owner_comment_fail:${failureId}`, title: 'Comentar' } }, + { type: 'reply', reply: { id: `owner_ignore_fail:${failureId}`, title: 'Ignorar' } }, + ], + }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-failure] owner alert error:', ownerErr.message); + } + } + return failureId; +} + +async function handleIa360FreeText(record) { + // PRODUCTION-HARDENING (fallback universal — cero silencio): estos dos flags viven a + // nivel de FUNCION (no del try) para que el catch terminal pueda verlos. + // - responded: se pone true cada vez que enviamos ALGO al contacto/owner. + // - dealFound: true SOLO cuando el contacto está dentro del embudo IA360 activo + // (pasó el guard `!deal`). El fallback/alerta SOLO aplica a deals existentes + // no-resueltos (la misión: "deal en estado no-match"), NUNCA a desconocidos sin + // deal (esos los maneja evaluateTriggers en paralelo; doblar respuesta = ruido). + let responded = false; + let dealFound = false; + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return; + if (!record.message_body || !String(record.message_body).trim()) return; + const deal = await getActiveNonTerminalIa360Deal(record); + if (!deal) return; // only inside an active, non-terminal IA360 funnel + dealFound = true; + const contactContext = deal.contact_context || await loadIa360ContactContext(record).catch(() => null); + if (deal.memory_mode === 'cliente_activo_beta_supervisado' || isIa360ClienteActivoBetaContact(contactContext)) { + const handled = await handleIa360ClienteActivoBetaLearning({ record, deal, contact: contactContext }); + if (handled) responded = true; + return; + } + const agent = await callIa360Agent({ record, stageName: deal.stage_name }); + if (!agent || !agent.reply) { + // Agent unavailable (n8n down / webhook unregistered) → holding reply, never silence. + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar esto y te confirmo en un momento.' }).catch(() => {}); + responded = true; + // D) ALERTA AL OWNER si el cerebro del bot (n8n) está caído/timeout. Además del + // holding-reply de arriba (el contacto ya quedó atendido → alreadyResponded:true), + // logueamos en ia360_bot_failures + alertamos a Alek, para que se entere de que el + // agente IA no respondió (no solo silencio operativo). + await handleIa360BotFailure({ + record, + reason: 'agente IA no disponible (n8n caído o timeout)', + alreadyResponded: true, + }).catch(e => console.error('[ia360-failure] agent-down alert error:', e.message)); + return; + } + + // C) LISTAR REUNIONES DEL CONTACTO ("¿cuáles tengo?"). VA ANTES del bloque + // "Reunión agendada": casi todo el que pregunta esto YA tiene cita → está en esa + // etapa, y ahí abajo caería al branch de reagendar. Es read-only (solo lee + // ia360_bookings), por eso es seguro responder y returnear sin tocar el estado. + console.log('[ia360-agent] contact=%s stage=%s action=%s intent=%s', record.contact_number, deal.stage_name, agent.action || '-', agent.intent || '-'); + // Reflejo CRM por interaccion (best-effort; no bloquea dispatch ni regresa agenda). + reflectIa360ToEspoCrm({ record, agent, channel: 'whatsapp' }).catch(() => {}); + if (agent.action === 'list_bookings' || agent.intent === 'list_bookings') { + const bookings = await loadIa360BookingsForList(record.contact_number); + console.log('[ia360-list] contact=%s bookings=%d', record.contact_number, bookings.length); + let body; + if (!bookings.length) { + body = 'Por ahora no tienes reuniones agendadas. ¿Quieres que agendemos una?'; + } else { + const lines = bookings.map((b, i) => `${i + 1}) ${fmtIa360Listado(b.start)}`).join(String.fromCharCode(10)); + const n = bookings.length; + const plural = n === 1 ? 'reunión agendada' : 'reuniones agendadas'; + body = `Tienes ${n} ${plural}:${String.fromCharCode(10)}${lines}`; + } + await enqueueIa360Text({ record, label: 'ia360_ai_list_bookings', body }); + responded = true; + return; + } + + // RESCHEDULE/CANCEL on an ALREADY-BOOKED meeting (deal at "Reunión agendada"): + // do NOT auto-offer slots → tapping one would CREATE a 2nd meeting without + // cancelling the existing one (double-book). True update needs the stored + // calendarEventId/zoomMeetingId per contact (not persisted yet). Cheap, coherent + // guard: acknowledge + hand off to Alek (Task), let him move/cancel the real event. + if (deal.stage_name === 'Reunión agendada') { + const isCancel = agent.action === 'cancel' + || /cancel|anul|ya no (podr|voy|asist)/i.test(String(record.message_body || '')); + + // ── CANCELAR INTELIGENTE (multi-cita): resolvemos QUE cita cancelar. + // - cancelDate = agent.date (YYYY-MM-DD CDMX, puede venir null si fue vago). + // - candidates = citas de ESE dia (si hay date) o TODAS (si no hubo date). + // - 1 candidato -> notificar al OWNER directo para esa cita (no re-preguntar). + // - >1 candidato -> mandar al CONTACTO una lista de esas citas (pickcancel:). + // - 0 candidatos -> avisar al contacto (segun si habia date o no habia ninguna). + // Todo va dentro del try/catch terminal de handleIa360FreeText. + if (isCancel) { + const bookings = await loadIa360Bookings(record.contact_number); + + // SIN ninguna cita guardada → NO molestar al owner. Responder al contacto. + if (!bookings.length) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_meeting', + body: 'No tienes reuniones activas a tu nombre para cancelar.', + }); + responded = true; + return; + } + + const cancelDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + const candidates = cancelDate + ? bookings.filter(b => ymdIa360CDMX(b.start) === cancelDate) + : bookings; + + // 0 candidatos con fecha pedida → no hay cita ese dia (pero si hay otras). + if (candidates.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_no_match', + body: 'No encuentro una reunión para esa fecha. ¿Me confirmas qué día era la que quieres cancelar?', + }); + responded = true; + return; + } + + // >1 candidato → el CONTACTO elige cual cancelar (lista interactiva). + if (candidates.length > 1) { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_cancel_pick', + messageBody: 'IA360: ¿cuál reunión cancelar?', + interactive: { + type: 'list', + header: { type: 'text', text: 'Cancelar reunión' }, + body: { text: 'Tienes varias reuniones agendadas. ¿Cuál quieres cancelar?' }, + footer: { text: 'IA360 · lo confirmo con Alek' }, + action: { + button: 'Ver reuniones', + sections: [{ + title: 'Tus reuniones', + rows: candidates.slice(0, 10).map(b => ({ + id: `pickcancel:${b.event_id}`, + title: String(fmtIa360Short(b.start)).slice(0, 24), + description: 'Toca para solicitar cancelarla', + })), + }], + }, + }, + }); + responded = true; + return; + } + + // 1 candidato → notificar al OWNER directo para ESA cita (sin re-preguntar). + const target = candidates[0]; + await enqueueIa360Text({ + record, + label: 'ia360_ai_cancel_request', + body: 'Dame un momento, lo confirmo con Alek.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió CANCELAR su reunión del ${fmtIa360Medium(target.start)} (CDMX). Mensaje: "${record.message_body || ''}". Acción humana: aprobar/cancelar el evento Calendar/Zoom existente y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] cancel handoff:', e.message)); + try { + await notifyOwnerCancelForBooking({ record, contactNumber: record.contact_number, booking: target }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on cancel failed:', ownerErr.message); + } + return; + } + + // ── NUEVO AGENDAMIENTO (multi-cita): el prospecto ya agendado pide OTRA reunión. + // NO es reagendar (no mueve la existente): debe CAER al handler de offer_slots de + // abajo, que consulta disponibilidad real y ofrece el menú multi-día. Por eso NO + // hacemos return aquí — solo seguimos el flujo. El cfm_ posterior hará append a + // ia360_bookings (segunda cita), sin tocar la primera. + // Gap#1: SOLO una intención REAL de reagendar dispara el handoff a Alek. Un mensaje + // normal (nurture/provide_info/ask_pain/smalltalk) NO es reagendar: debe CAER al + // reply DEFAULT de abajo (respuesta conversacional), no al handoff de reschedule. + const isReschedule = agent.action === 'reschedule' || agent.intent === 'reschedule' + || /reagend|reprogram|posponer|adelantar|recorr|mover la (reuni|cita|llamad)|cambi(ar|a|o)?.{0,18}(d[ií]a|hora|fecha|horario)|otro d[ií]a|otra hora|otro horario|otra fecha/i.test(String(record.message_body || '')); + if (agent.action === 'offer_slots' || agent.action === 'book') { + // fall-through: el control sale del bloque "Reunión agendada" y continúa al + // handler de offer_slots/book más abajo (no return). + } else if (isReschedule) { + // ── REAGENDAR: conserva ack + handoff (Alek mueve el evento a mano via la tarea + // de EspoCRM). NO se ofrece "Aprobar". + await enqueueIa360Text({ + record, + label: 'ia360_ai_reschedule_request', + body: 'Va, le paso a Alek que quieres mover la reunión. Él te confirma el nuevo horario por aquí en un momento.', + }); + responded = true; + emitIa360N8nHandoff({ + record, + eventType: 'meeting_reschedule_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto pidió REPROGRAMAR su reunión ya agendada. Mensaje: "${record.message_body || ''}". ${agent.date ? 'Fecha sugerida: ' + agent.date + '. ' : ''}Acción humana: mover el evento Calendar/Zoom existente a la nueva fecha (NO crear uno nuevo) y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] reschedule handoff:', e.message)); + return; + } + } + + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'texto-libre-ia'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_ai_agent_' + (agent.action || 'reply'), + ...(agent.extracted && agent.extracted.area_operacion ? { area_operacion: agent.extracted.area_operacion } : {}), + }, + }); + + // OPT-OUT → reply + move to lost. + if (agent.action === 'optout' || agent.intent === 'optout') { + await syncIa360Deal({ record, targetStageName: 'Perdido / no fit', titleSuffix: 'Opt-out (texto libre)', notes: `Opt-out por texto libre: ${record.message_body}` }); + await enqueueIa360Text({ record, label: 'ia360_ai_optout', body: agent.reply }); + responded = true; + return; + } + + // OFFER SLOTS / BOOK → consulta disponibilidad REAL y ofrece horarios. + // E2: desatorado. Antes el guard exigia `&& agent.date` y, sin fecha, caia al + // reply vago. Ahora la intencion de agendar SIEMPRE dispara disponibilidad: + // - con fecha: consulta ese dia; si 0 slots, cae al spread next-available. + // - sin fecha (o "¿cuando si hay?"): spread multi-dia desde manana (ignora el + // date del LLM, que a veces viene mal). El menu ya trae el dia en cada fila. + if (agent.action === 'offer_slots' || agent.action === 'book') { + // COMPUERTA offer_slots: NO empujar el calendario. El agente puede inferir + // agendar de una senal debil; pedimos confirmacion explicita. Solo el boton + // gate_slots_yes (manejado abajo con return) muestra los horarios. + await enqueueIa360Interactive({ record, label: 'ia360_gate_offer_slots', messageBody: 'IA360: confirmar horarios', interactive: { type: 'button', body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + '¿Quieres que te pase horarios para una llamada con Alek?' }, action: { buttons: [ { type: 'reply', reply: { id: 'gate_slots_yes', title: 'Sí, ver horarios' } }, { type: 'reply', reply: { id: 'gate_slots_no', title: 'Todavía no' } } ] } } }); + responded = true; + return; + try { + const url = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + // Sanitiza el date del LLM: solo una fecha ISO real (YYYY-MM-DD) es usable. A + // veces el modelo devuelve un placeholder sin resolver ("") o + // basura; en ese caso lo descartamos y caemos al barrido multi-día (no se + // intenta consultar disponibilidad con una fecha inválida). + const reqDate = agent.date && /^\d{4}-\d{2}-\d{2}$/.test(String(agent.date)) ? String(agent.date) : null; + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: 'Agenda (texto libre)', + notes: `Solicitó agenda por texto libre (${record.message_body}); fecha interpretada ${reqDate || 'sin fecha → próximos días'}; se consulta Calendar real`, + }); + // helper: una llamada al webhook de disponibilidad con payload arbitrario. + const callAvail = async (payload) => { + if (!url) return null; + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, ...payload }), + }); + return r.ok ? await r.json() : null; + } catch (e) { console.error('[ia360-agent] availability error:', e.message); return null; } + }; + // helper: emite la lista interactiva de WhatsApp (max 10 filas, title <=24). + const sendSlotList = async (rows, intro) => { + await enqueueIa360Interactive({ + record, + label: 'ia360_ai_available_slots', + messageBody: 'IA360: horarios disponibles', + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: (agent.reply ? agent.reply + String.fromCharCode(10) + String.fromCharCode(10) : '') + intro }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ title: 'Disponibles', rows: rows.slice(0, 10).map((slot) => ({ id: slot.id, title: String(slot.title).slice(0, 24), description: slot.description })) }], + }, + }, + }); + }; + + // 1) Si el usuario nombro un dia concreto (ISO válido), intenta ESE dia primero. + // A) reqDate con slots -> ofrece ESE dia (no el barrido). reqDate sin slots o + // consulta fallida -> cae al spread. dayQueryOk distingue "el dia salio VACIO" + // (lleno de verdad) de "la consulta FALLO" (null/timeout): no afirmamos "ya + // está lleno" cuando en realidad no pudimos consultar ese dia. + let dayQueryOk = false; + if (reqDate) { + const dayAvail = await callAvail({ date: reqDate }); + dayQueryOk = !!(dayAvail && Array.isArray(dayAvail.slots)); + const daySlots = (dayAvail && dayAvail.slots) || []; + if (daySlots.length > 0) { + await sendSlotList(daySlots, `Estos espacios de 1 hora estan libres (${dayAvail.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.`); + responded = true; + return; + } + // dia lleno (o consulta fallida) → cae al spread multi-dia (next-available) abajo. + } + + // 2) Spread multi-dia: proximos dias habiles con slots, ~2 por dia, dia en el titulo. + const spread = await callAvail({ nextAvailable: true }); + const spreadSlots = (spread && spread.slots) || []; + if (spreadSlots.length === 0) { + await enqueueIa360Text({ record, label: 'ia360_ai_no_slots', body: 'Revisé la agenda real de Alek y no encontré espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?' }); + responded = true; + return; + } + // B) Si pidió un dia ESPECIFICO y de verdad salió lleno (consulta OK pero sin + // slots, y distinto al primer dia del barrido), NOMBRA ese dia con su dia de + // semana en CDMX: "El miércoles 10 de junio ya está lleno." Solo cuando la + // consulta del dia fue exitosa-pero-vacia (dayQueryOk); si falló, NO afirmamos + // que está lleno (sería falso) y ofrecemos el barrido sin esa frase. + let fullDay = ''; + if (reqDate && dayQueryOk && reqDate !== spread.date) { + const diaNombre = fmtIa360DiaPedido(reqDate); + fullDay = diaNombre + ? `El ${diaNombre} ya está lleno. ` + : `Ese día ya está lleno. `; + } + await sendSlotList(spreadSlots, `${fullDay}Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.`); + responded = true; + return; + } catch (e) { + console.error('[ia360-agent] offer_slots/book handler error:', e.message); + await enqueueIa360Text({ record, label: 'ia360_ai_holding', body: 'Déjame revisar la agenda y te confirmo opciones en un momento.' }).catch(() => {}); + responded = true; + return; + } + } + + // DEFAULT → just send the agent's coherent reply (ask_pain / provide_info / smalltalk / other). + if (agent.action === 'advance_pain' || agent.intent === 'ask_pain') { + await syncIa360Deal({ record, targetStageName: 'Dolor calificado', titleSuffix: 'Dolor (texto libre)', notes: `Dolor por texto libre: ${record.message_body}${agent.extracted && agent.extracted.area_operacion ? ' (área: ' + agent.extracted.area_operacion + ')' : ''}` }); + } + const sentReply = await enqueueIa360Text({ record, label: 'ia360_ai_reply', body: agent.reply }); + if (sentReply) responded = true; + } catch (err) { + console.error('[ia360-agent] handleIa360FreeText error:', err.message); + // FALLBACK UNIVERSAL (catch): un error NUNCA debe dejar al contacto en silencio. + // Solo si el contacto estaba dentro del embudo IA360 (dealFound) — un fallo antes + // de saberlo NO debe responder ni alertar (lo cubre evaluateTriggers en paralelo). + if (dealFound) { + await handleIa360BotFailure({ + record, + reason: 'error: ' + (err && err.message ? String(err.message).slice(0, 120) : 'desconocido'), + alreadyResponded: responded, + }).catch(e => console.error('[ia360-failure] catch fallback error:', e.message)); + } + return; + } + + // FALLBACK UNIVERSAL (fin del handler): si llegamos al final SIN haber respondido y + // el contacto está en el embudo IA360 (dealFound) y el mensaje NO era pasivo, no lo + // dejamos en silencio: fallback al contacto + alerta al owner para que lo tome. + // (En el flujo lineal el reply default casi siempre marca responded=true; esta red + // cubre early-returns sin envío y el caso de enqueue duplicado/erróneo.) + if (!responded && dealFound && !isIa360PassiveMessage(record.message_body)) { + await handleIa360BotFailure({ + record, + reason: 'no-manejado', + alreadyResponded: false, + }).catch(e => console.error('[ia360-failure] end-net fallback error:', e.message)); + } +} + +// FlowWire Part B: universal nfm_reply router. When a prospect SUBMITS a WhatsApp Flow, +// Meta sends an inbound interactive whose interactive.nfm_reply.response_json (a JSON STRING) +// carries the answered fields + flow_token. The button state machine never matches it, so we +// detect + route here BEFORE the button handler. Flow is identified by FIELDS PRESENT (robust, +// not token-bound). Everything is wrapped in try/catch so a malformed nfm never tumbles the +// webhook. Returns true if it routed (caller short-circuits), false otherwise. +function extractIa360NfmResponse(record) { + try { + const payload = typeof record.raw_payload === 'string' ? JSON.parse(record.raw_payload) : record.raw_payload; + const messages = payload?.entry?.[0]?.changes?.[0]?.value?.messages || []; + const msg = messages.find(m => m && m.id === record.message_id) || messages[0]; + const nfm = msg?.interactive?.nfm_reply; + if (!nfm || !nfm.response_json) return null; + const data = typeof nfm.response_json === 'string' ? JSON.parse(nfm.response_json) : nfm.response_json; + return (data && typeof data === 'object') ? data : null; + } catch (_) { + return null; + } +} + +const URGENCIA_LEGIBLE = { + esta_semana: 'algo de esta semana', + este_mes: 'algo de este mes', + este_trimestre: 'algo de este trimestre', + explorando: 'exploración', +}; + +// W4 — etiquetas legibles para no mostrar ids crudos (15m_50m, 6_20, taller_capacitacion) al +// contacto. Se usan en la respuesta del offer_router. Fallback al id si no hay etiqueta. +const TAMANO_LEGIBLE = { + menos_5m: 'menos de 5M', '5m_15m': '5M a 15M', '15m_50m': '15M a 50M', '50m_200m': '50M a 200M', mas_200m: 'más de 200M', +}; +const PERSONAS_LEGIBLE = { + '1_5': '1 a 5 personas', '6_20': '6 a 20 personas', '21_50': '21 a 50 personas', '51_100': '51 a 100 personas', mas_100: 'más de 100 personas', +}; +const SOLUCION_LEGIBLE = { + taller_capacitacion: 'un taller de capacitación', servicio_productizado: 'un servicio productizado', + saas_aiaas: 'una plataforma SaaS/AIaaS', consultoria_premium: 'consultoría premium', aun_no_se: 'la opción que mejor te encaje', +}; + +async function handleIa360FlowReply(record) { + try { + if (!record || record.direction !== 'incoming' || record.message_type !== 'interactive') return false; + const data = extractIa360NfmResponse(record); + if (!data) return false; + + // Identify WHICH flow by the fields present (not by token). + if (data.area !== undefined && data.urgencia !== undefined) { + // ── DIAGNOSTICO ─────────────────────────────────────────────────────── + const { area, urgencia, fuga, sistema, resultado } = data; + console.log('[ia360-flowwire] event=diagnostic_answered contact=%s area=%s urgencia=%s', record.contact_number, area, urgencia); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['diagnostico-ia360', 'dolor:' + area, 'urgencia:' + urgencia], + customFields: { + ia360_dolor: area, + ia360_fuga: fuga, + ia360_sistema: sistema, + ia360_urgencia: urgencia, + ia360_resultado: resultado, + }, + }); + const hot = ['esta_semana', 'este_mes', 'este_trimestre'].includes(urgencia); + if (hot) { + await syncIa360Deal({ + record, + targetStageName: 'Requiere Alek', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_hot', + messageBody: 'IA360 Flow: diagnóstico (urgente)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Listo. Con lo que me diste —${area} / ${fuga}— ya tengo tu caso. Como es ${URGENCIA_LEGIBLE[urgencia] || 'algo prioritario'}, lo sensato es agendar 30 min con Alek y bajarlo a quick wins.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_urgent', title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Primero el mapa' } }, + ] }, + }, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Diagnóstico (Flow)', + notes: `diagnostic_answered: ${area} / ${urgencia}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_diagnostic_explore', + messageBody: 'IA360 Flow: diagnóstico (explorando)', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Te dejo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable lo volvemos mapa.' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_see_example', title: 'Ver ejemplo' } }, + { type: 'reply', reply: { id: '100m_want_map', title: 'Quiero mapa' } }, + ] }, + }, + }); + } + // BONUS C: avisar al OWNER que el contacto respondio el diagnostico. + try { + const who = record.contact_name || record.contact_number; + await sendOwnerInteractive({ + record, + label: 'owner_flow_diagnostic', + messageBody: `IA360: ${who} respondió el diagnóstico`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Diagnóstico respondido' }, + body: { text: `Alek, ${who} respondió el diagnóstico. Dolor: ${area || 'n/d'}, urgencia: ${URGENCIA_LEGIBLE[urgencia] || urgencia || 'n/d'}.${resultado ? ' Resultado buscado: ' + resultado + '.' : ''}` }, + footer: { text: 'IA360 · humano en el bucle' }, + action: { buttons: [ + { type: 'reply', reply: { id: `owner_take:${record.contact_number}`, title: 'Lo tomo yo' } }, + { type: 'reply', reply: { id: `owner_book:${record.contact_number}`, title: 'Agendar' } }, + { type: 'reply', reply: { id: `owner_nurture:${record.contact_number}`, title: 'Nutrir' } }, + ] }, + }, + }); + } catch (ownerErr) { + console.error('[ia360-owner] notify on flow diagnostic failed:', ownerErr.message); + } + return true; + } + + if (data.tamano_empresa !== undefined) { + // ── OFFER_ROUTER ────────────────────────────────────────────────────── + const { tamano_empresa, personas_afectadas, tipo_solucion, presupuesto, nivel_decision } = data; + console.log('[ia360-flowwire] event=offer_router_answered contact=%s tamano=%s presupuesto=%s', record.contact_number, tamano_empresa, presupuesto); + const tier = (presupuesto === '200k_1m' || presupuesto === 'mas_1m') ? 'Premium' + : (presupuesto === '50k_200k') ? 'Pro' : 'Starter'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['oferta:' + tier, 'decisor:' + nivel_decision], + customFields: { ia360_oferta_sugerida: tier }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Propuesta / siguiente paso', + titleSuffix: 'Oferta ' + tier + ' (Flow)', + notes: `offer_router_answered: ${tamano_empresa} / ${presupuesto} → ${tier}`, + }); + const tamanoTxt = TAMANO_LEGIBLE[tamano_empresa] || tamano_empresa; + const personasTxt = PERSONAS_LEGIBLE[personas_afectadas] || personas_afectadas; + const solucionTxt = SOLUCION_LEGIBLE[tipo_solucion] || tipo_solucion; + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_offer_router', + messageBody: 'IA360 Flow: oferta sugerida ' + tier, + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: `Gracias. Por tu perfil (facturación ${tamanoTxt}, ${personasTxt}) lo sensato es arrancar con ${solucionTxt}, en nivel ${tier}. El siguiente paso es una llamada de 20 minutos con Alek para aterrizarlo a tu caso.` }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + return true; + } + + if (data.empresa !== undefined && data.rol !== undefined) { + // ── PRE_CALL ────────────────────────────────────────────────────────── + const { empresa, rol, objetivo, sistemas } = data; + console.log('[ia360-flowwire] event=pre_call_intake_submitted contact=%s empresa=%s rol=%s', record.contact_number, empresa, rol); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['pre-call-ia360'], + customFields: { + ia360_empresa: empresa, + ia360_rol: rol, + ia360_objetivo: objetivo, + ia360_sistemas: sistemas, + }, + }); + // W4 fix anti-lazo (per FLOWS doc: pre_call es stage-aware). Si el contacto YA tiene una + // reunión agendada, NO lo empujamos a re-agendar (eso causaba el lazo booking→contexto→ + // re-agenda→booking); cerramos con acuse terminal. Solo si NO hay slot ofrecemos agendar. + const preCallBookings = await loadIa360Bookings(record.contact_number); + const preCallHasSlot = Array.isArray(preCallBookings) && preCallBookings.length > 0; + if (preCallHasSlot) { + await syncIa360Deal({ + record, + targetStageName: 'Reunión agendada', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol} (con reunión ya agendada)`, + }); + const ultimaCita = preCallBookings[preCallBookings.length - 1]?.start; + await enqueueIa360Text({ + record, + label: 'ia360_flow_pre_call_booked', + body: `Gracias, con esto Alek llega preparado a tu reunión${ultimaCita ? ' del ' + fmtIa360Short(ultimaCita) : ''}. No necesitas hacer nada más; nos vemos ahí.`, + }); + } else { + await syncIa360Deal({ + record, + targetStageName: 'Agenda en proceso', + titleSuffix: 'Pre-call (Flow)', + notes: `pre_call_intake_submitted: ${empresa} / ${rol}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_flow_pre_call', + messageBody: 'IA360 Flow: pre-call intake', + interactive: { + type: 'button', + header: { type: 'image', image: { link: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg' } }, + body: { text: 'Gracias, con esto Alek llega preparado (nada de demo genérica). ¿Agendamos la llamada?' }, + footer: { text: 'IA360' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar llamada' } }, + ] }, + }, + }); + } + return true; + } + + if (data.preferencia !== undefined) { + // ── PREFERENCES ─────────────────────────────────────────────────────── + const { preferencia } = data; + if (preferencia === 'no_contactar') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['no-contactar'], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=opt_out contact=%s preferencia=no_contactar', record.contact_number); + await syncIa360Deal({ + record, + targetStageName: 'Perdido / no fit', + titleSuffix: 'Opt-out (Flow)', + notes: 'opt_out: no_contactar', + }); + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences_optout', + body: 'Entendido, te saco de esta secuencia. Aquí estoy si algún día quieres retomarlo.', + }); + } else { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['preferencia:' + preferencia], + customFields: { ia360_preferencia: preferencia }, + }); + console.log('[ia360-flowwire] event=nurture_selected contact=%s preferencia=%s', record.contact_number, preferencia); + await syncIa360Deal({ + record, + targetStageName: 'Nutrición', + titleSuffix: 'Preferencias (Flow)', + notes: `nurture_selected: ${preferencia}`, + }); + // W4 fix: nurture = nutrir, NO empujar a ventas (per FLOWS doc). Acuse terminal sin + // botón de "Ver ejemplo" (ese re-metía al embudo y contribuía a la sensación de lazo). + await enqueueIa360Text({ + record, + label: 'ia360_flow_preferences', + body: 'Listo, ajustado. Te mando solo lo útil y sin saturarte. Cuando quieras retomar, aquí estoy.', + }); + } + return true; + } + + // nfm_reply with unrecognized shape → don't route, let normal handling proceed (it won't match either). + return false; + } catch (err) { + console.error('[ia360-flowwire] nfm router error (degraded, no route):', err.message); + return false; + } +} + +async function handleIa360LiteInteractive(record) { + if (!record || record.direction !== 'incoming' || !['interactive', 'button'].includes(record.message_type)) return; + // ── HITL: rama OWNER (Alek). Va ANTES de flow-reply y del funnel: un tap del + // owner (ids con prefijo 'owner_') jamas debe caer en el embudo del prospecto. + // getInteractiveReplyId ya devuelve el id en minusculas; por eso los ids owner + // se emiten en minusculas con guion_bajo y el contactNumber sobrevive (digitos). + const ownerReplyId = getInteractiveReplyId(record); + if (ownerReplyId && ownerReplyId.startsWith('owner_')) { + if (normalizePhone(record.contact_number) !== IA360_OWNER_NUMBER) { + console.warn('[ia360-owner] ignored owner-prefixed reply from non-owner contact=%s id=%s', record.contact_number || '-', ownerReplyId); + return; + } + try { + const [ownerAction, ownerArg, ownerPipe] = ownerReplyId.split(':'); + // call/keep cargan el numero del contacto (digit-strip OK). owner_cancel_yes + // carga el EVENT_ID (alfanumerico) → NO digit-stripear ese arg. + const targetContact = (ownerArg || '').replace(/\D/g, ''); + // BANDEJA DE IDEAS: ruteo de la tarjeta (Producción/Documentar/CRM/Rechazar). + if (ownerAction && ownerAction.startsWith('owner_idea_')) { + await handleIa360OwnerIdeaRoute({ record, ownerAction, ideaId: ownerArg }); + return; + } + if (ownerAction === 'owner_pipe') { + await handleIa360OwnerPipelineChoice({ record, targetContact, pipeline: ownerPipe }); + return; + } + if (ownerAction === 'owner_seq') { + await handleIa360OwnerSequenceChoice({ record, targetContact, sequenceId: ownerPipe }); + return; + } + // APPROVE-SEND: decisiones de la tarjeta de aprobación post-readout. + if (ownerAction === 'owner_approve_send') { + await handleIa360OwnerApproveSend({ record, targetContact, sequenceId: ownerPipe }); + return; + } + if (ownerAction === 'owner_approve_edit') { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_approve_edit_ack', body: `Ok, el borrador para ${targetContact} queda SIN enviar. Edita el copy y vuelve a elegir secuencia cuando esté listo.`, targetContact, ownerBudget: true }); + return; + } + if (ownerAction === 'owner_approve_keep') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'guardar' }); + return; + } + if (ownerAction === 'owner_approve_dnc') { + await handleIa360TerminalVcardChoice({ record, targetContact, terminalChoice: 'excluir' }); + return; + } + if (ownerAction === 'owner_approve_manual') { + await handleIa360OwnerApproveManual({ record, targetContact }); + return; + } + if (ownerAction === 'owner_vcard_pipe' || ownerAction === 'owner_vcard_take' || ownerAction === 'owner_vcard_keep') { + await handleIa360OwnerVcardAction({ record, ownerAction, targetContact }); + return; + } + if (ownerAction === 'owner_cancel_yes') { + // MULTI-CITA: el boton trae el EVENT_ID de la cita concreta a cancelar. + // `record` aqui es Alek, asi que resolvemos contacto+cita por event_id + // (case-insensitive). Cancelamos con el event_id ORIGINAL guardado (NO el + // del boton, que pasa por toLowerCase y podria no coincidir en Google). + const eventArg = ownerArg || ''; + const found = await findBookingByEventId(eventArg); + if (!found) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_notfound', body: `No encontré la cita (${eventArg}). Puede que ya estuviera cancelada. No cancelé nada.` }); + return; + } + const cancelContact = String(found.contact_number || '').replace(/\D/g, ''); + const evt = found.event_id || ''; // ORIGINAL (case preservado) desde la DB + const zoom = found.zoom_id || ''; + const startRaw = found.start || ''; + const cancelRes = await cancelIa360Booking({ calendarEventId: evt, zoomMeetingId: zoom }); + const startFmt = fmtIa360Medium(startRaw); + if (cancelRes.ok) { + // Quita ESA cita del array del contacto (y limpia campos sueltos si era la ultima). + const remaining = await removeIa360Booking({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, eventId: evt }); + await mergeContactIa360State({ waNumber: found.wa_number || record.wa_number, contactNumber: cancelContact, tags: ['cancelada-aprobada'], customFields: { ultimo_cta_enviado: 'ia360_cancel_aprobada' } }); + await sendIa360DirectText({ record, toNumber: cancelContact, label: 'ia360_cancel_done_contact', body: `Listo, Alek aprobó. Cancelé tu reunión del ${startFmt.replace(/\.$/, '')}. Si quieres retomar, escríbeme por aquí.` }); + // Si ya no le quedan reuniones, saca el deal de "Reunión agendada". + if (remaining.length === 0) { + await syncIa360Deal({ + record: { ...record, contact_number: cancelContact }, + targetStageName: 'Requiere Alek', + titleSuffix: 'Reunión cancelada', + notes: `Reunión cancelada (aprobada por Alek). Event ${evt}. Sin reuniones activas → vuelve a Requiere Alek.`, + }).catch(e => console.error('[ia360-multicita] syncIa360Deal on empty:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión de ${cancelContact}. Ya no le quedan reuniones; moví el deal a "Requiere Alek".` }); + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_done', body: `Hecho, cancelada la reunión del ${startFmt} de ${cancelContact}. ${remaining.length === 1 ? 'Le queda 1 reunión' : `Le quedan ${remaining.length} reuniones`}.` }); + } + } else { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_failed', body: `No pude cancelar la cita de ${cancelContact} (el webhook falló). Revísalo manual.` }); + } + return; + } + if (ownerAction === 'owner_cancel_call') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_call_contact', body: 'Alek te va a llamar para verlo.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_call_ack', body: `Ok, le avisé a ${targetContact} que lo llamas.` }); + return; + } + if (ownerAction === 'owner_cancel_keep') { + await sendIa360DirectText({ record, toNumber: targetContact, label: 'ia360_cancel_keep_contact', body: 'Tu reunión sigue en pie.' }); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_cancel_keep_ack', body: `Ok, la reunión de ${targetContact} se mantiene.` }); + return; + } + // BONUS C acks: owner_take / owner_book / owner_nurture (FYI flow-reply). + if (ownerAction === 'owner_take') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_ack', body: `Ok, tú tomas a ${targetContact}.` }); return; } + if (ownerAction === 'owner_book') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_book_ack', body: `Ok, a agendar con ${targetContact}.` }); return; } + if (ownerAction === 'owner_nurture') { await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_nurture_ack', body: `Ok, ${targetContact} a nutrición.` }); return; } + + // ── PRODUCTION-HARDENING: cierre del loop de fallos del bot ─────────────── + // Estos botones llegan de la alerta de handleIa360BotFailure. El `ownerArg` + // (aquí: `ownerArg` crudo, NO `targetContact`) es el ID numérico de la fila en + // coexistence.ia360_bot_failures. Cada acción actualiza el status de esa fila. + if (ownerAction === 'owner_take_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='lo_tomo', status='lo_tomo' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] take update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_take_fail_ack', body: 'Tomado: queda para gestión manual tuya.' }); + return; + } + if (ownerAction === 'owner_ignore_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + if (fid) await pool.query(`UPDATE coexistence.ia360_bot_failures SET owner_action='ignorado', status='ignorado' WHERE id=$1`, [fid]).catch(e => console.error('[ia360-failure] ignore update:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_ignore_fail_ack', body: 'Ok, lo ignoro.' }); + return; + } + if (ownerAction === 'owner_comment_fail') { + const fid = String(ownerArg || '').replace(/\D/g, ''); + // Marca al CONTACTO owner como "esperando comentario para la fila ": el + // SIGUIENTE texto del owner se captura como owner_comment (ver dispatch). Se + // guarda en custom_fields del contacto owner; el record aquí ES el owner. + if (fid) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: fid }, + }).catch(e => console.error('[ia360-failure] set awaiting:', e.message)); + } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_prompt', body: 'Escribe tu comentario para mejorar el bot:' }); + return; + } + // owner_* desconocido: ack neutro + return (NUNCA cae al funnel). + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_unknown_ack', body: 'Recibido.' }); + return; + } catch (ownerErr) { + console.error('[ia360-owner] owner branch error:', ownerErr.message); + return; // no tumbar el webhook + } + } + + // ── MULTI-CITA: el CONTACTO eligio cual cita cancelar desde la lista (pickcancel: + // ). Esto NO empieza con 'owner_', lo dispara el prospecto. Resolvemos la + // cita por event_id (case-insensitive) y notificamos al OWNER para ESA cita. try/catch + // propio: nunca tumba el webhook. Va ANTES de flow-reply y del funnel. + if (ownerReplyId && ownerReplyId.startsWith('pickcancel:')) { + try { + const pickedEventId = ownerReplyId.slice('pickcancel:'.length); + const found = await findBookingByEventId(pickedEventId); + if (!found) { + await enqueueIa360Text({ record, label: 'ia360_pickcancel_notfound', body: 'No encontré esa reunión, puede que ya esté cancelada. ¿Me confirmas qué día era?' }); + return; + } + await enqueueIa360Text({ record, label: 'ia360_ai_cancel_request', body: 'Dame un momento, lo confirmo con Alek.' }); + emitIa360N8nHandoff({ + record, + eventType: 'meeting_cancel_requested', + targetStage: 'Reunión agendada', + priority: 'high', + summary: `El contacto eligió CANCELAR su reunión del ${fmtIa360Medium(found.start)} (CDMX). Acción humana: aprobar/cancelar el evento Calendar/Zoom y confirmar al contacto.`, + }).catch(e => console.error('[ia360-n8n] pickcancel handoff:', e.message)); + await notifyOwnerCancelForBooking({ + record, + contactNumber: String(found.contact_number || record.contact_number).replace(/\D/g, ''), + booking: { start: found.start || '', event_id: found.event_id || '', zoom_id: found.zoom_id || '' }, + }); + } catch (pickErr) { + console.error('[ia360-multicita] pickcancel branch error:', pickErr.message); + } + return; + } + + // FlowWire Part B: a submitted WhatsApp Flow (nfm_reply) is NOT a button — route it first. + if (await handleIa360FlowReply(record)) return; + const answer = String(record.message_body || '').trim().toLowerCase(); + const replyId = getInteractiveReplyId(record); + + // Pipeline 5 "WhatsApp Revenue OS": respuesta a la apertura (PASO 1) y bifurcación + // (PASO 3). Gateado por custom_fields.ia360_revenue_state; si no aplica, devuelve + // false y el flujo sigue normal. Va ANTES del embudo 100m / agenda. + if (await handleRevenueOsButton({ record, replyId })) return; + + // ── G-C: ruteo de respuestas a openers v2 (ids seq_* y alias de template) ── + // Va DESPUÉS de Revenue OS (que resuelve su propio "Sí, cuéntame" gateado por + // estado) y ANTES del embudo 100M. Un id seq_* del catálogo NUNCA cae al + // fallback global. + if (replyId && replyId.startsWith('seq_')) { + if (await handleIa360SequenceReply({ record, replyId })) return; + } else if (replyId || answer) { + const aliasKey = String(replyId || answer || '').trim().toLowerCase(); + if (IA360_SEQ_ALIAS_NEGATIVE.has(aliasKey) || IA360_SEQ_ALIAS_HANDOFF.has(aliasKey) || IA360_SEQ_ALIAS_AFFIRMATIVE.has(aliasKey)) { + try { + const aliasContact = await loadIa360ContactContext(record).catch(() => null); + const aliased = resolveIa360TemplateButtonAlias({ replyId: aliasKey, contact: aliasContact }); + if (aliased && await handleIa360SequenceReply({ record, replyId: aliased, contact: aliasContact })) return; + } catch (aliasErr) { + console.error('[ia360-seq] alias error:', aliasErr.message); + } + } + } + + // W4 — boton "Enviar contexto" (stage-aware): sin slot abre diagnostico, con slot abre + // pre_call. Va ANTES del embudo para no confundirse con un micro-paso. Si el Flow no se + // pudo encolar (dedup/cuenta), cae al flujo normal (este id no matchea = no-op silencioso). + if (replyId === '100m_send_context') { + try { + const sent = await dispatchContextFlow(record); + if (sent) return; + } catch (ctxErr) { + console.error('[ia360-flowwire] dispatchContextFlow failed:', ctxErr.message); + } + } + + // IA360 100M WhatsApp prospecting flow: coherent stage machine for approved templates. + const reply100m = { + 'diagnóstico rápido': { + stage: 'Intención detectada', tag: 'problema-reconocido', title: 'Diagnóstico rápido', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Perfecto. Para que esto no sea genérico: ¿cuál de estos síntomas te cuesta más dinero o tiempo hoy?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'ver mapa 30-60-90': { + stage: 'Diagnóstico enviado', tag: 'mapa-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Va. El mapa 30-60-90 necesita ubicar primero la fuga principal: operación manual, datos tardíos o seguimiento comercial. ¿Cuál quieres atacar primero?', + buttons: [ + { id: '100m_capture_manual', title: 'Captura manual' }, + { id: '100m_reports_late', title: 'Reportes tarde' }, + { id: '100m_sales_followup', title: 'Seguimiento ventas' }, + ], + }, + 'no ahora': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'No ahora', + body: 'Perfecto, no te saturo. Te dejo una regla simple: si una tarea se repite, depende de Excel/WhatsApp o retrasa decisiones, probablemente hay oportunidad IA360.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'captura manual': { + stage: 'Dolor calificado', tag: 'dolor-captura-manual', title: 'Captura manual', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ese suele ser quick win: reducir doble captura y pasar datos entre WhatsApp, CRM, ERP o Excel sin depender de copiar/pegar. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'reportes tarde': { + stage: 'Dolor calificado', tag: 'dolor-reportes-tarde', title: 'Reportes tarde', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Aquí el valor está en convertir datos operativos en alertas y tablero semanal, no en esperar reportes manuales. ¿Qué ejemplo quieres ver?', + buttons: [ + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + ], + }, + 'seguimiento ventas': { + stage: 'Dolor calificado', tag: 'dolor-seguimiento-ventas', title: 'Seguimiento ventas', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + body: 'Ahí IA360 puede clasificar intención, mover pipeline y crear tareas humanas antes de que se enfríe el lead. ¿Qué mecanismo quieres ver?', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_agent_followup', title: 'Agente follow-up' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + ], + }, + 'whatsapp → crm': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-whatsapp-crm', title: 'WhatsApp → CRM', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: mensaje entra → se clasifica intención → aplica tags/campos → mueve deal → si hay alta intención crea tarea humana. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'erp → bi': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-erp-bi', title: 'ERP → BI', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: ERP/CRM/Excel → datos normalizados → dashboard ejecutivo → alertas → decisiones semanales. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'agente follow-up': { + stage: 'Propuesta / siguiente paso', tag: 'mecanismo-agentic-followup', title: 'Agente follow-up', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/bi_solucion.jpg', + body: 'Flujo: agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de comprometer algo sensible. ¿Qué hacemos con este caso?', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_see_example', title: 'Ver ejemplo' }, + ], + }, + 'quiero mapa': { + stage: 'Diagnóstico enviado', tag: 'mapa-30-60-90-solicitado', title: 'Mapa 30-60-90', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Mapa base 30-60-90:\n\n30 días: detectar cuello de botella, quick win y reglas de control humano.\n60 días: conectar WhatsApp/CRM/ERP/BI y medir tiempos, fugas y seguimiento.\n90 días: primer agente o tablero operativo con gobierno, métricas y handoff humano.\n\nAhora sí: ¿qué tan prioritario es aterrizarlo a tu caso?', + buttons: [ + { id: '100m_urgent', title: 'Sí, urgente' }, + { id: '100m_exploring', title: 'Estoy explorando' }, + { id: '100m_not_priority', title: 'No prioritario' }, + ], + }, + 'ver ejemplo': { + stage: 'Propuesta / siguiente paso', tag: 'ejemplo-solicitado', title: 'Ver ejemplo', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Ejemplo corto: WhatsApp detecta interés, ForgeChat etiqueta y mueve pipeline, n8n crea tarea en CRM, y Alek recibe resumen antes de hablar con el prospecto.', + buttons: [ + { id: '100m_want_map', title: 'Quiero mapa' }, + { id: '100m_schedule', title: 'Agendar' }, + { id: '100m_urgent', title: 'Sí, urgente' }, + ], + }, + 'sí, urgente': { + stage: 'Requiere Alek', tag: 'hot-lead', title: 'Sí, urgente', + mediaKey: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + body: 'Marcado como prioridad alta. Siguiente paso sensato: Alek revisa contexto y propone llamada con objetivo claro, no demo genérica.', + buttons: [ + { id: 'sched_today', title: 'Hoy' }, + { id: 'sched_tomorrow', title: 'Mañana' }, + { id: 'sched_week', title: 'Esta semana' }, + ], + }, + 'estoy explorando': { + stage: 'Nutrición', tag: 'explorando', title: 'Estoy explorando', + body: 'Bien. Te mantengo en modo exploración: ejemplos concretos, sin presión. Cuando veas un caso aplicable, lo convertimos en mapa.', + buttons: [ + { id: '100m_wa_crm', title: 'WhatsApp → CRM' }, + { id: '100m_erp_bi', title: 'ERP → BI' }, + { id: '100m_schedule', title: 'Agendar' }, + ], + }, + // G-C anti-loop: "No prioritario" ya NO ofrece "Aplicarlo" (reabría la rama + // comercial); las salidas son nutrición ("Más adelante") o baja. + 'no prioritario': { + stage: 'Nutrición', tag: 'no-prioritario', title: 'No prioritario', + body: 'Perfecto. Lo dejo en nutrición suave. Si después detectas doble captura, reportes tarde o leads sin seguimiento, ahí sí vale la pena retomarlo.', + buttons: [ + { id: '100m_more_later', title: 'Más adelante' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'más adelante': { + stage: 'Nutrición', tag: 'nutricion-suave', title: 'Más adelante', + body: 'Listo. Queda para más adelante; no avanzo a venta. Te mandaré solo recursos de criterio/diagnóstico cuando tenga sentido.', + buttons: [ + { id: '100m_apply_later', title: 'Aplicarlo' }, + { id: '100m_optout', title: 'Baja' }, + ], + }, + 'baja': { + stage: 'Perdido / no fit', tag: 'no-contactar', title: 'Baja', + body: 'Entendido. Marco este contacto como no contactar para esta secuencia.', + buttons: [], + }, + }; + + const key100mById = { + '100m_diagnostico_rapido': 'diagnóstico rápido', + '100m_capture_manual': 'captura manual', + '100m_reports_late': 'reportes tarde', + '100m_sales_followup': 'seguimiento ventas', + '100m_wa_crm': 'whatsapp → crm', + '100m_erp_bi': 'erp → bi', + '100m_agent_followup': 'agente follow-up', + '100m_want_map': 'quiero mapa', + '100m_schedule': 'agendar', + '100m_see_example': 'ver ejemplo', + '100m_urgent': 'sí, urgente', + '100m_exploring': 'estoy explorando', + '100m_not_priority': 'no prioritario', + '100m_more_later': 'más adelante', + '100m_apply_later': 'aplicarlo', + '100m_optout': 'baja', + }; + const flow100m = reply100m[key100mById[replyId]] || reply100m[replyId] || reply100m[answer]; + if (flow100m) { + // ── G-C: anti-loop del router 100M ────────────────────────────────────── + // 'baja' (optout) SIEMPRE pasa: la salida del contacto no se bloquea nunca. + if (flow100m.tag !== 'no-contactar') { + try { + const guard = await ia360HundredMAdvancedGuard(record); + // Guard de estado/versión: la conversación ya avanzó a agenda/reunión/ + // handoff humano → un botón de un mensaje viejo NO reabre la rama. + if (guard.advanced) { + await enqueueIa360Text({ record, label: 'ia360_100m_continuity', body: guard.body }); + return; + } + // Nodo loop-prone repetido → versión condensada con salidas terminales, + // no el bloque completo otra vez. Si la lectura del contacto falló, NO se + // escribe el mapa de visitas (se pisaría con un objeto vacío). + const visited = guard.visited || {}; + if (guard.visitedOk && IA360_100M_LOOP_PRONE.has(flow100m.tag)) { + if (visited[flow100m.tag]) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: (Number(visited[flow100m.tag]) || 0) + 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + await enqueueIa360Interactive({ + record, + label: 'ia360_100m_condensed', + messageBody: `IA360 100M: ${flow100m.title} (resumen)`, + interactive: { + type: 'button', + body: { text: `Eso ya lo vimos: ${flow100m.title}. Para no darte vueltas con lo mismo, mejor dime cómo cerramos: ¿agendamos una llamada corta con Alek o lo dejamos para más adelante?` }, + footer: { text: 'IA360 · sin vueltas' }, + action: { + buttons: [ + { type: 'reply', reply: { id: '100m_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada con Alek' } }, + { type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }, + ], + }, + }, + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_100m_visited: { ...visited, [flow100m.tag]: 1 } }, + }).catch(e => console.error('[ia360-100m] visited merge:', e.message)); + } + } catch (guardErr) { + console.error('[ia360-100m] guard error:', guardErr.message); + } + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'prospecting-100m', flow100m.tag], + customFields: { + campana_ia360: 'IA360 100M WhatsApp prospecting', + fuente_origen: 'whatsapp-template-100m', + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: `ia360_100m_${flow100m.tag}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: flow100m.stage, + titleSuffix: flow100m.title, + notes: `100M flow: ${record.message_body} → ${flow100m.stage}`, + }); + // FlowWire Part A: "Diagnóstico rápido" opens the diagnostico WhatsApp Flow instead of + // the button chain. tag 'problema-reconocido' is unique to that node. If the Flow can't be + // enqueued (dedup / account / throw), fall through to the existing button branch below. + if (flow100m.tag === 'problema-reconocido') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '995344356550872', + screen: 'DIAGNOSTICO', + cta: 'Abrir diagnóstico', + bodyText: 'Para no darte algo genérico, cuéntame en 30 segundos dónde se te va el tiempo o el dinero. Lo aterrizo a tu caso.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/dolor_ceo.jpg', + flowToken: 'ia360_diagnostico', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] diagnostico flow send failed, falling back to buttons:', flowErr.message); + } + } + // UX guardrail: si el usuario pide mapa, primero se entrega un mapa real en el + // mensaje interactivo de abajo. No abrir offer_router aquí; eso cambiaba la promesa + // de "Quiero mapa" a "Ver mi oferta" y generaba fricción/loop comercial. + // W4 — preferences: nodos "Baja" (no-contactar) y "No ahora"/"Más adelante" (nutricion-suave). + // Lanza el Flow de preferencias granular; fallback al texto/botones del nodo si el envio falla. + if (flow100m.tag === 'no-contactar' || flow100m.tag === 'nutricion-suave') { + try { + const flowSent = await enqueueIa360FlowMessage({ + record, + flowId: '4037415283227252', + screen: 'PREFERENCIAS', + cta: 'Elegir preferencia', + bodyText: 'Para no saturarte: dime cómo prefieres que sigamos en contacto. Lo eliges en 10 segundos y respeto tu decisión.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_preferences', + label: `ia360_100m_${flow100m.tag}`, + }); + if (flowSent) return; + } catch (flowErr) { + console.error('[ia360-flowwire] preferences flow send failed, falling back to buttons:', flowErr.message); + } + } + if (flow100m.buttons.length > 0) { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360 · micro-paso' }, + action: { buttons: flow100m.buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })) }, + }, + }); + } else { + await enqueueIa360Interactive({ + record, + label: `ia360_100m_${flow100m.tag}`, + messageBody: `IA360 100M: ${flow100m.title}`, + interactive: { + type: 'button', + header: flow100m.mediaKey ? { type: 'image', image: { link: flow100m.mediaKey } } : { type: 'text', text: flow100m.title }, + body: { text: flow100m.body }, + footer: { text: 'IA360' }, + action: { buttons: [{ type: 'reply', reply: { id: '100m_more_later', title: 'Más adelante' } }] }, + }, + }); + } + return; + } + + if (answer === 'diagnóstico' || answer === 'diagnostico') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-og4-diagnostico'], + customFields: { + campana_ia360: 'IA360 WhatsApp lite flow', + fuente_origen: 'whatsapp-interactive', + ultimo_cta_enviado: 'ia360_lite_inicio', + servicio_recomendado: 'Diagnóstico IA360', + ia360_ultima_respuesta: 'Diagnóstico', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Intención detectada', + titleSuffix: 'Diagnóstico', + notes: `Input: ${record.message_body}; intención inicial detectada`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_area_after_diagnostico', + messageBody: 'IA360: elegir área de dolor', + interactive: { + type: 'list', + header: { type: 'text', text: 'Área de dolor' }, + body: { text: 'Va. Para aterrizarlo rápido: ¿dónde duele más hoy?' }, + footer: { text: 'Elige una opción' }, + action: { + button: 'Elegir área', + sections: [{ + title: 'Áreas frecuentes', + rows: [ + { id: 'pain_sales', title: 'Ventas', description: 'Seguimiento, leads, cierre' }, + { id: 'pain_ops', title: 'Operación', description: 'Procesos, doble captura' }, + { id: 'pain_bi', title: 'Datos / BI', description: 'Reportes y decisiones tardías' }, + { id: 'pain_erp', title: 'ERP / CRM', description: 'Integraciones y visibilidad' }, + { id: 'pain_ai_gov', title: 'Gobierno IA', description: 'Reglas, seguridad, control' }, + ], + }], + }, + }, + }); + return; + } + + const areaMap = { + 'pain_sales': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'pain_ops': { tag: 'interes-synapse', area: 'Operación' }, + 'pain_bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'pain_erp': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'pain_ai_gov': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'ventas': { tag: 'interes-og4-diagnostico', area: 'Ventas' }, + 'operación': { tag: 'interes-synapse', area: 'Operación' }, + 'operacion': { tag: 'interes-synapse', area: 'Operación' }, + 'datos / bi': { tag: 'interes-datapower', area: 'Datos / BI' }, + 'erp / crm': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'erp / bi': { tag: 'interes-erp-integraciones', area: 'ERP / CRM' }, + 'gobierno ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + 'agentes ia': { tag: 'interes-gobierno-ia', area: 'Gobierno IA' }, + }; + const mapped = areaMap[replyId] || areaMap[answer]; + if (mapped) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', mapped.tag, 'intencion-detectada'], + customFields: { + area_dolor: mapped.area, + ia360_ultima_respuesta: mapped.area, + ultimo_cta_enviado: 'ia360_lite_area_dolor', + servicio_recomendado: mapped.area === 'ERP / CRM' ? 'ERP / Integraciones / BI' : 'Diagnóstico IA360', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: mapped.area, + notes: `Área de dolor seleccionada: ${mapped.area}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_next_step_after_area', + messageBody: `IA360: siguiente paso para ${mapped.area}`, + interactive: { + type: 'button', + header: { type: 'text', text: mapped.area }, + body: { text: 'Perfecto. Con eso ya puedo perfilar el caso. ¿Qué prefieres como siguiente paso?' }, + footer: { text: 'Sin compromiso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'next_5q', title: '5 preguntas' } }, + { type: 'reply', reply: { id: 'next_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'next_example', title: 'Enviar ejemplo' } }, + ], + }, + }, + }); + return; + } + + if (answer === '5 preguntas') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'diagnostico-enviado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_5_preguntas', + ia360_ultima_respuesta: '5 preguntas', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Diagnóstico enviado', + titleSuffix: 'Diagnóstico solicitado', + notes: 'Solicitó diagnóstico ligero de 5 preguntas', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q2_manual_work', + messageBody: 'IA360 pregunta ligera: dónde hay trabajo manual', + interactive: { + type: 'list', + header: { type: 'text', text: 'Pregunta 1/5' }, + body: { text: 'Empecemos suave: ¿dónde ves más trabajo manual o doble captura?' }, + footer: { text: 'Una opción basta' }, + action: { + button: 'Elegir punto', + sections: [{ + title: 'Puntos frecuentes', + rows: [ + { id: 'manual_whatsapp', title: 'WhatsApp', description: 'Seguimiento y mensajes manuales' }, + { id: 'manual_excel', title: 'Excel', description: 'Captura y reportes manuales' }, + { id: 'manual_erp', title: 'ERP', description: 'Datos/reprocesos entre sistemas' }, + { id: 'manual_crm', title: 'CRM', description: 'Seguimiento comercial' }, + { id: 'manual_reports', title: 'Reportes', description: 'Dashboards o KPIs tardíos' }, + ], + }], + }, + }, + }); + return; + } + + const manualPainMap = { + manual_whatsapp: { label: 'WhatsApp', tag: 'interes-whatsapp-business' }, + manual_excel: { label: 'Excel', tag: 'interes-datapower' }, + manual_erp: { label: 'ERP', tag: 'interes-erp-integraciones' }, + manual_crm: { label: 'CRM', tag: 'interes-erp-integraciones' }, + manual_reports: { label: 'Reportes', tag: 'interes-datapower' }, + }; + const manualPain = manualPainMap[replyId]; + if (manualPain) { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'respondio-diagnostico', manualPain.tag], + customFields: { + dolor_principal: `Trabajo manual / doble captura en ${manualPain.label}`, + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'ia360_lite_q1_manual_work', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Respondió preguntas', + titleSuffix: `Dolor ${manualPain.label}`, + notes: `Pregunta 1/5: trabajo manual o doble captura en ${manualPain.label}`, + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_q1_ack_next', + messageBody: `IA360: dolor manual ${manualPain.label}`, + interactive: { + type: 'button', + header: { type: 'text', text: manualPain.label }, + body: { text: `Entendido: hay fricción en ${manualPain.label}. Para seguir ligero, puedo mostrar arquitectura, ejemplo aplicado o pedir agenda.` }, + footer: { text: 'Una opción basta' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'agendar' || replyId === '100m_schedule' || replyId === 'next_schedule' || replyId === 'wa_schedule') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_agendar', + ia360_ultima_respuesta: 'Agendar', + proximo_followup: 'Alek debe proponer horario', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId: 'wa_schedule', answer: record.message_body }, 'Agenda en proceso'), + titleSuffix: 'Agendar', + notes: 'Solicitó agendar; se mueve a agenda en proceso hasta confirmar Calendar/Zoom', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_schedule_window', + messageBody: 'IA360: preferencia para agendar', + interactive: { + type: 'button', + header: { type: 'text', text: 'Agenda' }, + body: { text: 'Va. Para que Alek te proponga horario: ¿qué ventana te acomoda mejor?' }, + footer: { text: 'Luego se conecta calendario/CRM' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'sched_today', title: 'Hoy' } }, + { type: 'reply', reply: { id: 'sched_tomorrow', title: 'Mañana' } }, + { type: 'reply', reply: { id: 'sched_week', title: 'Esta semana' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'enviar ejemplo') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_enviar_ejemplo', + ia360_ultima_respuesta: 'Enviar ejemplo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: 'Dolor calificado', + titleSuffix: 'Ejemplo solicitado', + notes: 'Solicitó ejemplo IA360', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_selector', + messageBody: 'IA360: selector de ejemplo', + interactive: { + type: 'list', + header: { type: 'text', text: 'Ejemplos IA360' }, + body: { text: 'Claro. ¿Qué ejemplo quieres ver primero?' }, + footer: { text: 'Elige uno' }, + action: { + button: 'Ver ejemplos', + sections: [{ + title: 'Casos rápidos', + rows: [ + { id: 'ex_erp_bi', title: 'ERP → BI', description: 'Dashboard ejecutivo y alertas' }, + { id: 'ex_wa_crm', title: 'WhatsApp', description: 'Lead, tags, pipeline y tarea CRM' }, + { id: 'ex_agent_followup', title: 'Agente follow-up', description: 'Seguimiento con humano en control' }, + { id: 'ex_gov_ai', title: 'Gobierno IA', description: 'Reglas, roles y seguridad' }, + ], + }], + }, + }, + }); + return; + } + + if (replyId === 'ex_wa_crm' || answer === 'whatsapp → crm' || answer === 'whatsapp -> crm') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-whatsapp-business', 'ejemplo-solicitado'], + customFields: { + ultimo_cta_enviado: 'ia360_lite_example_whatsapp_crm', + ia360_ultima_respuesta: 'WhatsApp → CRM', + servicio_recomendado: 'WhatsApp Revenue OS / CRM conversacional', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'WhatsApp Revenue OS', + notes: 'Solicitó ejemplo WhatsApp → CRM; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_whatsapp_crm_example', + messageBody: 'IA360 ejemplo: WhatsApp a CRM', + interactive: { + type: 'button', + header: { type: 'text', text: 'WhatsApp → CRM' }, + body: { text: 'Ejemplo: un mensaje entra por WhatsApp, se etiqueta por intención, cae en pipeline, crea/actualiza contacto en CRM y genera tarea humana si hay alta intención.' }, + footer: { text: 'Este mismo flujo ya empezó aquí' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_flow_map', title: 'Ver flujo' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (answer === 'hoy' || answer === 'mañana' || answer === 'manana' || answer === 'esta semana' || replyId === 'sched_today' || replyId === 'sched_tomorrow' || replyId === 'sched_week') { + const day = replyId === 'sched_today' || answer === 'hoy' + ? 'today' + : (replyId === 'sched_week' || answer === 'esta semana' ? 'this_week' : 'tomorrow'); + const availability = await requestIa360Availability({ record, day }); + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'requiere-alek', 'hot-lead', 'reunion-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Elegir hora disponible para: ${record.message_body}`, + ultimo_cta_enviado: 'ia360_lite_agenda_slots', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('agenda_preference_selected', 'Agenda en proceso'), + titleSuffix: `Agenda ${record.message_body}`, + notes: `Preferencia de día seleccionada: ${record.message_body}; se consultó disponibilidad real de Calendar para ofrecer horas libres`, + }); + const slots = availability?.slots || []; + if (slots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_no_slots', + body: `Revisé la agenda real de Alek y no veo espacios libres de 1 hora en esa ventana.\n\nElige otra opción de día o escribe una ventana específica y lo revisamos antes de confirmar.`, + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots', + messageBody: `IA360: horarios disponibles ${availability.date}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: `Revisé Calendar de Alek. Estos espacios de 1 hora están libres (${availability.date}, hora CDMX). Elige uno y lo confirmo con Calendar + Zoom.` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: slots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: slot.title, + description: slot.description, + })), + }], + }, + }, + }); + return; + } + + // CFM-STEP (paso de confirmación, aditivo): al TOCAR un horario (slot_) NO + // agendamos al instante. Interceptamos y mandamos un mensaje interactivo de + // confirmación con dos botones: "Sí, agendar" (cfm_) y "Ver otras" (reslots). + // El booking real solo ocurre con cfm_ (abajo). Todo va en try/catch terminal: + // si algo falla, log + return (NUNCA cae al booking — ese es justo el riesgo que + // este paso existe para prevenir). + const tappedSlot = parseIa360SlotId(replyId); + if (tappedSlot) { + try { + // El id ya viene en minúsculas (getInteractiveReplyId). Reconstruimos el id de + // confirmación conservando el mismo ISO codificado: slot_<...> -> cfm_<...>. + const isoSuffix = replyId.slice('slot_'.length); // ej '20260605t160000z' + const confirmId = `cfm_${isoSuffix}`; // ej 'cfm_20260605t160000z' + const promptTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(tappedSlot.start)); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_slot_confirm_prompt', + messageBody: `IA360: confirmar horario ${promptTime}`, + interactive: { + type: 'button', + header: { type: 'text', text: 'Confirmar reunión' }, + body: { text: `¿Confirmas tu reunión con Alek el ${promptTime} (hora del centro de México)?` }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + buttons: [ + { type: 'reply', reply: { id: confirmId, title: 'Sí, agendar' } }, + { type: 'reply', reply: { id: 'reslots', title: 'Ver otras' } }, + ], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] slot confirm prompt error:', e.message); + } + return; + } + + // CFM-STEP: re-mostrar el menú de horarios ("Ver otras"). Re-dispara el spread + // multi-día (nextAvailable) y reconstruye la lista interactiva inline (mismo shape + // que el path de día). try/catch terminal: si falla, log + texto de respaldo. + if (replyId === 'gate_slots_no') { + await enqueueIa360Text({ record, label: 'ia360_gate_slots_no', body: 'Va, sin prisa. Cuando quieras ver horarios para hablar con Alek, dime y te paso opciones.' }); + return; + } + if (replyId === 'reslots' || replyId === 'gate_slots_yes') { + try { + const availUrl = process.env.N8N_IA360_AVAILABILITY_WEBHOOK_URL; + let spread = null; + if (availUrl) { + const r = await fetch(availUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'forgechat-ia360-webhook', workStartHour: 10, workEndHour: 18, slotMinutes: 60, nextAvailable: true }), + }); + spread = r.ok ? await r.json() : null; + } + const reSlots = (spread && spread.slots) || []; + if (reSlots.length === 0) { + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_none', + body: 'Revisé la agenda real de Alek y no veo espacios de 1 hora libres en los próximos días hábiles. ¿Te late que le pase a Alek que te contacte para cuadrar un horario?', + }); + return; + } + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_available_slots_reslots', + messageBody: `IA360: horarios disponibles${spread.date ? ' ' + spread.date : ''}`, + interactive: { + type: 'list', + header: { type: 'text', text: 'Horarios libres' }, + body: { text: 'Te paso opciones de los próximos días (hora CDMX). Elige una y la confirmo con Calendar + Zoom.' }, + footer: { text: 'Se revalida antes de reservar' }, + action: { + button: 'Elegir hora', + sections: [{ + title: 'Disponibles', + rows: reSlots.slice(0, 10).map((slot) => ({ + id: slot.id, + title: String(slot.title).slice(0, 24), + description: slot.description, + })), + }], + }, + }, + }); + } catch (e) { + console.error('[ia360-calendar] reslots handler error:', e.message); + await enqueueIa360Text({ + record, + label: 'ia360_lite_reslots_holding', + body: 'Déjame revisar la agenda y te confirmo opciones en un momento.', + }); + } + return; + } + + // CFM-STEP: booking REAL. Solo ocurre cuando el prospecto pulsa "Sí, agendar" + // (cfm_). Aquí vive TODO el flujo de reserva que antes corría al tocar el slot: + // parse -> bookIa360Slot (Zoom + Calendar) -> mutar estado/etapa -> confirmación + // final -> handoff n8n. El id de confirmación carga el mismo ISO; lo reescribimos a + // slot_ para reutilizar parseIa360SlotId verbatim (replyId ya en minúsculas). + const confirmedSlot = replyId.startsWith('cfm_') + ? parseIa360SlotId(`slot_${replyId.slice('cfm_'.length)}`) + : null; + if (confirmedSlot) { + const booking = await bookIa360Slot({ record, ...confirmedSlot }); + if (!booking?.ok) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_slot_busy', + body: 'Ese horario ya no está disponible o no pude confirmarlo. Por seguridad no lo agendé. Vuelve a elegir día para mostrar horarios libres actualizados.', + }); + return; + } + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'reunion-confirmada', 'zoom-creado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + proximo_followup: `Reunión confirmada: ${booking.start}`, + ultimo_cta_enviado: 'ia360_lite_reunion_confirmada', + // HITL/compat: campos sueltos de la ULTIMA cita (legacy). La fuente de verdad + // para multi-cita es `ia360_bookings` (append abajo), NO estos campos. + ia360_booking_event_id: booking.calendarEventId || '', + ia360_booking_zoom_id: booking.zoomMeetingId || '', + ia360_booking_start: booking.start || '', + }, + }); + // MULTI-CITA: en vez de SOBREESCRIBIR, AGREGAMOS esta cita al array de reservas. + // try/catch propio: si el append falla, la cita ya quedo en los campos sueltos + // (la confirmacion y el deal no se bloquean). + try { + await appendIa360Booking({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + booking: { start: booking.start || '', event_id: booking.calendarEventId || '', zoom_id: booking.zoomMeetingId || '' }, + }); + } catch (apErr) { + console.error('[ia360-multicita] append on booking failed:', apErr.message); + } + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + titleSuffix: 'Reunión confirmada', + notes: `Reunión confirmada. Calendar event: ${booking.calendarEventId}; Zoom meeting: ${booking.zoomMeetingId}; inicio: ${booking.start}`, + }); + const confirmedTime = new Intl.DateTimeFormat('es-MX', { + timeZone: 'America/Mexico_City', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(booking.start)); + let calLink = ""; + try { + const _calToken = require("crypto").randomBytes(18).toString("base64url"); + const _endUtc = (confirmedSlot && confirmedSlot.end) || new Date(new Date(booking.start).getTime() + 3600000).toISOString(); + const _exp = new Date(new Date(booking.start).getTime() + 36*3600000).toISOString(); + await pool.query("INSERT INTO coexistence.ia360_meeting_links (token,event_id,contact_number,kind,start_utc,end_utc,summary,zoom_join_url,expires_at) VALUES ($1,$2,$3,\x27cal\x27,$4,$5,$6,$7,$8) ON CONFLICT (token) DO NOTHING", [_calToken, booking.calendarEventId || "", record.contact_number, booking.start, _endUtc, "Reunion con Alek (TransformIA)", booking.zoomJoinUrl || "", _exp]); + calLink = "\n\nAgrega la cita a tu calendario:\nhttps://wa.geekstudio.dev/api/r/" + _calToken; + } catch (clErr) { console.error("[ia360-cal] booking token failed:", clErr.message); } + await enqueueIa360Text({ + record, + label: 'ia360_100m_schedule_confirmed', + body: `Listo, tu reunión con Alek quedó agendada para ${confirmedTime} (hora CDMX).\n\n${booking.zoomJoinUrl ? 'Aquí tu enlace de Zoom para conectarte:\n' + booking.zoomJoinUrl : 'En un momento te confirmo el enlace de Zoom.'}${calLink}\n\n¡Nos vemos!`, + }); + // W4 D2 — ofrecer "Enviar contexto" ahora que YA hay slot (el helper stage-aware lo manda a + // pre_call). Aditivo: NO altera el mensaje de confirmacion (critico) y no rompe el booking. + try { + await enqueueIa360Interactive({ + record, + label: 'ia360_postbooking_send_context', + messageBody: 'IA360: ofrecer enviar contexto pre-llamada', + dedupSuffix: ':sendctx', + interactive: { + type: 'button', + header: { type: 'text', text: 'Antes de la reunión (opcional)' }, + body: { text: 'Si quieres que Alek llegue con tu contexto a la mano, mándamelo en 30 segundos. Si no, así ya quedó listo y nos vemos en la reunión.' }, + footer: { text: 'IA360 · opcional' }, + action: { buttons: [ + { type: 'reply', reply: { id: '100m_send_context', title: 'Enviar contexto' } }, + ] }, + }, + }); + } catch (ctxErr) { + console.error('[ia360-flowwire] post-booking send-context offer failed:', ctxErr.message); + } + await emitIa360N8nHandoff({ + record, + eventType: 'meeting_confirmed_calendar_zoom', + targetStage: getIa360StageForEvent('meeting_confirmed_calendar_zoom', 'Reunión agendada'), + priority: 'high', + summary: `Reunión confirmada con Calendar/Zoom. Calendar event ${booking.calendarEventId}; Zoom meeting ${booking.zoomMeetingId}; inicio ${booking.start}.`, + }); + return; + } + + if (replyId === 'ex_erp_bi' || answer === 'erp → bi' || answer === 'erp -> bi') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'interes-datapower', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: 'ERP → BI', + servicio_recomendado: 'DataPower BI / ERP analytics', + ultimo_cta_enviado: 'ia360_lite_example_erp_bi', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: 'ERP → BI', + notes: 'Solicitó ejemplo ERP → BI; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda', + }); + await enqueueIa360Interactive({ + record, + label: 'ia360_lite_example_erp_bi_detail', + messageBody: 'IA360 ejemplo: ERP → BI', + interactive: { + type: 'button', + header: { type: 'text', text: 'ERP → BI' }, + body: { text: 'Ejemplo: conectamos ERP/CRM, normalizamos datos, generamos dashboard ejecutivo y alertas para decidir sin esperar reportes manuales.' }, + footer: { text: 'Caso DataPower BI' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'ex_agent_followup' || replyId === 'ex_gov_ai') { + const isGov = replyId === 'ex_gov_ai'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', isGov ? 'interes-gobierno-ia' : 'interes-agentic-automation', 'ejemplo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + servicio_recomendado: isGov ? 'Gobierno IA' : 'Agentic Follow-up', + ultimo_cta_enviado: isGov ? 'ia360_lite_example_gov_ai' : 'ia360_lite_example_agent_followup', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ejemplo ${record.message_body}; se mantiene como mecanismo elegido hasta que pida aplicarlo/costo/agenda`, + }); + await enqueueIa360Interactive({ + record, + label: isGov ? 'ia360_lite_example_gov_ai_detail' : 'ia360_lite_example_agent_followup_detail', + messageBody: `IA360 ejemplo: ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isGov ? 'Ejemplo: definimos reglas, roles, permisos, bitácora y criterios de escalamiento para usar IA sin perder control.' : 'Ejemplo: un agente detecta intención, prepara respuesta, actualiza CRM y escala al humano antes de compromisos sensibles.' }, + footer: { text: 'Humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'wa_flow_map' || replyId === 'wa_apply' || answer === 'ver flujo' || answer === 'aplicarlo') { + const isApply = replyId === 'wa_apply' || answer === 'aplicarlo'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isApply ? 'requiere-alek' : 'flujo-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: isApply ? 'ia360_lite_aplicar' : 'ia360_lite_ver_flujo', + proximo_followup: isApply ? 'Alek debe convertir a propuesta/implementación' : 'Enviar mapa visual del flujo WhatsApp → CRM', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isApply ? 'Requiere Alek' : 'Dolor calificado'), + titleSuffix: record.message_body, + notes: `Solicitó ${record.message_body} del ejemplo WhatsApp → CRM`, + }); + await enqueueIa360Interactive({ + record, + label: isApply ? 'ia360_lite_apply_next' : 'ia360_lite_flow_map', + messageBody: isApply ? 'IA360: aplicar flujo WhatsApp Revenue OS' : 'IA360: mapa del flujo WhatsApp → CRM', + interactive: isApply ? { + type: 'button', + header: { type: 'text', text: 'Aplicarlo' }, + body: { text: 'Perfecto. Lo convertiría así: 1) objetivos y reglas, 2) inbox + tags, 3) pipeline, 4) handoff humano, 5) medición. ¿Qué quieres que prepare?' }, + footer: { text: 'Siguiente paso comercial' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'apply_scope', title: 'Alcance' } }, + { type: 'reply', reply: { id: 'apply_cost', title: 'Costo' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + } : { + type: 'button', + header: { type: 'text', text: 'Flujo WhatsApp → CRM' }, + body: { text: 'Mapa: 1) entra mensaje, 2) se clasifica intención, 3) aplica tags/campos, 4) crea o mueve deal, 5) si hay alta intención crea tarea humana, 6) responde con el siguiente micro-paso.' }, + footer: { text: 'UX ligera + humano en control' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'flow_architecture', title: 'Arquitectura' } }, + { type: 'reply', reply: { id: 'wa_apply', title: 'Aplicarlo' } }, + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + ], + }, + }, + }); + return; + } + + if (replyId === 'apply_call') { + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', 'requiere-alek', 'llamada-solicitada'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: 'apply_call_terminal', + proximo_followup: 'Alek debe proponer llamada y objetivo', + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForEvent('call_requested', 'Agenda en proceso'), + titleSuffix: 'Llamada', + notes: 'Solicitó llamada; falta crear evento real en calendario/Zoom', + }); + // W4 — pre_call: al pedir llamada, abre el Flow de contexto pre-llamada (captura empresa/rol/ + // objetivo/sistemas/buen resultado). Cae al texto de handoff si el envio falla. El handoff a + // n8n/Alek (call_requested) se dispara SIEMPRE despues, independiente del Flow. + let preCallSent = false; + try { + preCallSent = await enqueueIa360FlowMessage({ + record, + flowId: '862907796864124', + screen: 'PRE_CALL_INTAKE', + cta: 'Enviar contexto', + bodyText: 'Para que Alek llegue preparado a tu llamada (no demo de cajón): cuéntame empresa, tu rol, el objetivo, los sistemas que usan hoy y qué sería un buen resultado.', + mediaUrl: 'https://wa.geekstudio.dev/ia360-bca/transformacion.jpg', + flowToken: 'ia360_pre_call', + label: 'ia360_apply_call_precall', + }); + } catch (flowErr) { + console.error('[ia360-flowwire] pre_call flow send (apply_call) failed, falling back to text:', flowErr.message); + } + if (!preCallSent) { + await enqueueIa360Text({ + record, + label: 'ia360_100m_call_terminal_handoff', + body: 'Listo: lo marco como solicitud de llamada. No envío más opciones automáticas aquí para no dar vueltas.\n\nSiguiente paso humano: Alek confirma objetivo, horario y enlace. En la siguiente fase n8n debe crear tarea en EspoCRM y evento Zoom/Calendar automáticamente.', + }); + } + await emitIa360N8nHandoff({ + record, + eventType: 'call_requested', + targetStage: 'Agenda en proceso', + priority: 'high', + summary: 'El contacto pidió llamada. Crear tarea humana, preparar resumen y proponer horario/enlace.', + }); + return; + } + + if (replyId === 'flow_architecture' || replyId === 'apply_scope' || replyId === 'apply_cost' || replyId === 'apply_call') { + const isCall = replyId === 'apply_call'; + const isCost = replyId === 'apply_cost'; + const isScope = replyId === 'apply_scope'; + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + tags: ['campana-ia360', 'hot-lead', isCall ? 'requiere-alek' : 'detalle-solicitado'], + customFields: { + ia360_ultima_respuesta: record.message_body, + ultimo_cta_enviado: replyId, + proximo_followup: isCall ? 'Alek debe proponer llamada' : `Enviar detalle: ${record.message_body}`, + }, + }); + await syncIa360Deal({ + record, + targetStageName: getIa360StageForReply({ replyId, answer: record.message_body }, isCall ? 'Agenda en proceso' : (isCost ? 'Propuesta / siguiente paso' : 'Requiere Alek')), + titleSuffix: record.message_body, + notes: `Solicitó detalle: ${record.message_body}`, + }); + await enqueueIa360Interactive({ + record, + label: `ia360_lite_${replyId}`, + messageBody: `IA360: detalle ${record.message_body}`, + interactive: { + type: 'button', + header: { type: 'text', text: record.message_body }, + body: { text: isCost ? 'Para costo real necesito alcance: canales, volumen, integraciones y nivel de IA/humano. Puedo preparar rango inicial o agendar revisión.' : isScope ? 'Alcance base: WhatsApp inbox, tags/campos, pipeline, 3-5 microflujos, handoff humano y medición. Luego se conecta CRM/n8n.' : isCall ? 'Va. Esto ya requiere humano: Alek debe proponerte horario y revisar objetivo, stack e integración.' : 'Arquitectura: WhatsApp Cloud API → ForgeChat inbox/flows → pipeline/tags → n8n/EspoCRM → tareas/resumen humano.' }, + footer: { text: 'Siguiente micro-paso' }, + action: { + buttons: [ + { type: 'reply', reply: { id: 'wa_schedule', title: 'Agendar' } }, + { type: 'reply', reply: { id: 'apply_call', title: 'Llamada' } }, + ], + }, + }, + }); + return; + } + + // ── FALLBACK GLOBAL DE INTERACTIVE (openers v2) ──────────────────────────── + // Si llegamos aquí, NINGÚN handler reconoció el button/list reply (id viejo o + // malformado). Los ids seq_* del catálogo y los quick replies de template con + // estado persona-first ya se rutean arriba (handleIa360SequenceReply + alias); + // aquí solo cae lo verdaderamente desconocido. El contacto siempre recibe + // acuse y el owner se entera. try/catch terminal: nunca tumba el webhook. + try { + const fallbackId = replyId || answer || '(sin id)'; + console.warn('[ia360-fallback] unhandled interactive reply contact=%s id=%s body=%s', record.contact_number || '-', fallbackId, String(record.message_body || '').slice(0, 80)); + if (normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_ack', + body: `Recibí tu respuesta "${String(record.message_body || fallbackId).slice(0, 60)}", pero aún no tengo una acción conectada para ese botón (${fallbackId}). No hice ningún cambio.`, + }); + return; + } + await enqueueIa360Text({ + record, + label: 'ia360_interactive_fallback', + body: 'Recibí tu respuesta y la estoy ubicando para darte una respuesta útil. Si es urgente, Alek también puede escribirte directo.', + }); + await sendIa360DirectText({ + record, + toNumber: IA360_OWNER_NUMBER, + label: 'owner_interactive_fallback_notice', + body: `Alek, ${record.contact_name || record.contact_number} (${record.contact_number}) respondió "${String(record.message_body || fallbackId).slice(0, 60)}" (id: ${fallbackId}) y no tengo un manejador para esa opción. Le acusé recibo; revisa si quieres tomarlo tú.`, + targetContact: record.contact_number, + ownerBudget: true, + }); + } catch (fbErr) { + console.error('[ia360-fallback] interactive fallback error:', fbErr.message); + } +} + + +/** + * POST /api/webhook/whatsapp + * Receives raw Meta WhatsApp webhook payloads forwarded by n8n. + * No auth required — called by internal n8n instance. + */ +// ============================================================================ +// CANARY Brain v2 — enrutamiento reversible por allowlist. Fuera de la allowlist +// (o con el flag off) este codigo es NO-OP: el monolito se comporta igual para +// todos los demas numeros. Cuando IA360_BRAIN_V2_CANARY='on' y el remitente esta +// en IA360_BRAIN_V2_ALLOWLIST, el TEXTO entrante se enruta al Brain v2 (workflow +// b74vYWxP5YT8dQ2H, path /webhook/ia360-brain-v2-test) en vez del monolito. +// Egress UNICO via messageSender (sendIa360DirectText / handleIa360FreeText). +// - Prefijo "/sim " => v2 trata al owner como CONTACTO simulado (force_actor) +// y genera respuesta conversacional (rama responder_llm). +// - owner directo (sin /sim) => v2 route owner_operator => SIN reply. +// - intent agendamiento (con /sim) => handback al booking existente del monolito. +// SOLO intercepta message_type='text'; interactivos/botones del owner (cancelar, +// calendario) siguen yendo al monolito intactos. Reversible: apagar el flag o +// vaciar la allowlist restituye el monolito sin redeploy de codigo. +// ============================================================================ +const IA360_BRAIN_V2_CANARY_ON = process.env.IA360_BRAIN_V2_CANARY === 'on'; +const IA360_BRAIN_V2_ALLOWLIST = new Set( + String(process.env.IA360_BRAIN_V2_ALLOWLIST || '') + .split(/[,\s]+/).map(x => x.replace(/\D/g, '')).filter(Boolean) +); +const IA360_BRAIN_V2_URL = process.env.N8N_IA360_BRAIN_V2_URL || 'https://n8n.geekstudio.dev/webhook/ia360-brain-v2-test'; +const IA360_BRAIN_V2_SIM_PREFIX = '/sim'; +console.log('[brain-v2-canary] boot canary=%s allowlist=%d url=%s', + IA360_BRAIN_V2_CANARY_ON ? 'on' : 'off', IA360_BRAIN_V2_ALLOWLIST.size, IA360_BRAIN_V2_URL); + +function ia360BrainV2CanaryEligible(record) { + if (!IA360_BRAIN_V2_CANARY_ON) return false; + if (!record || record.direction !== 'incoming' || record.message_type !== 'text') return false; + if (!String(record.message_body || '').trim()) return false; + return IA360_BRAIN_V2_ALLOWLIST.has(normalizePhone(record.contact_number)); +} + +async function callBrainV2({ contactWaNumber, message, forceActor }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 90000); + try { + const res = await fetch(IA360_BRAIN_V2_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contact_wa_number: contactWaNumber, message, force_actor: forceActor || '' }), + signal: controller.signal, + }); + if (!res.ok) { console.error('[brain-v2-canary] n8n failed:', res.status); return null; } + const text = await res.text(); + if (!text || !text.trim()) { console.error('[brain-v2-canary] empty body status=%s', res.status); return null; } + let parsed; + try { parsed = JSON.parse(text); } catch (e) { console.error('[brain-v2-canary] bad JSON:', e.message); return null; } + return Array.isArray(parsed) ? (parsed[0] || null) : parsed; + } catch (err) { + console.error('[brain-v2-canary] error:', err.name === 'AbortError' ? 'timeout 30000ms' : err.message); + return null; + } finally { + clearTimeout(timer); + } +} + +async function handleBrainV2Canary(record) { + const raw = String(record.message_body || '').trim(); + let message = raw; + let forceActor = ''; + const lower = raw.toLowerCase(); + if (lower === IA360_BRAIN_V2_SIM_PREFIX || lower.startsWith(IA360_BRAIN_V2_SIM_PREFIX + ' ')) { + forceActor = 'contact'; + message = raw.slice(IA360_BRAIN_V2_SIM_PREFIX.length).trim(); + if (!message) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_sim_empty', body: 'Modo simulacion Brain v2: escribe "/sim " para que te responda como contacto.' }); + return; + } + } + console.log('[brain-v2-canary] routing contact=%s force_actor=%s msg=%j', record.contact_number, forceActor || '-', message.slice(0, 80)); + const out = await callBrainV2({ contactWaNumber: record.contact_number, message, forceActor }); + if (!out) { + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_holding', body: 'Brain v2: no pude generar respuesta en este momento. Reintenta en un momento.' }); + return; + } + const branch = out.branch || out.route || 'fallback'; + console.log('[brain-v2-canary] branch=%s intent=%s actor=%s', branch, out.intent || '-', out.actor_type || '-'); + if (branch === 'responder_llm') { + const reply = String(out.reply_text || '').trim(); + if (!reply) { console.warn('[brain-v2-canary] responder_llm sin reply_text'); return; } + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'brainv2_responder', body: reply }); + return; + } + if (branch === 'agendamiento_handback') { + // El booking vive en el monolito: reinyectamos el texto (sin /sim) al agente + // de agenda existente, tratando al owner como contacto simulado. + const handbackRecord = Object.assign({}, record, { message_body: message }); + console.log('[brain-v2-canary] handback -> booking existente del monolito'); + await handleIa360FreeText(handbackRecord).catch(e => console.error('[brain-v2-canary] handback error:', e.message)); + return; + } + // owner_operator / system_excluded / fallback => SIN reply (por diseno). + console.log('[brain-v2-canary] sin reply (branch=%s)', branch); +} + +router.post('/webhook/whatsapp', async (req, res) => { + try { + // Authenticity: this endpoint is necessarily unauthenticated (public), so + // the control is Meta's HMAC signature. When META_APP_SECRET is configured + // we REJECT anything unsigned/invalid; if it's not set we log a warning so + // operators know inbound webhooks are unverified (forgeable). + const sig = verifyMetaSignature(req); + if (sig === false) { + return res.status(403).json({ error: 'Invalid webhook signature' }); + } + if (sig === null) { + console.warn('[webhook] META_APP_SECRET not set — inbound webhook signature NOT verified (set it to reject forged payloads).'); + } + + const payload = req.body; + if (!payload) { + return res.status(400).json({ error: 'Empty payload' }); + } + + // Support both array of payloads (n8n batch) and single payload + const payloads = Array.isArray(payload) ? payload : [payload]; + const allRecords = []; + for (const p of payloads) { + const records = parseMetaPayload(p); + allRecords.push(...records); + } + + if (allRecords.length === 0) { + // Acknowledge non-message webhooks (e.g. verification, errors) + return res.status(200).json({ ok: true, stored: 0 }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const r of allRecords) { + // Status receipts (sent/delivered/read/failed) update the ORIGINAL + // message's status — they must never create a chat row. Inserting them + // produced phantom "Status: delivered" bubbles. If no matching message + // exists (e.g. an app-sent message we don't track), this is a no-op. + if (r.message_type === 'status') { + await client.query( + `UPDATE coexistence.chat_history SET status = $1 WHERE message_id = $2`, + [r.status, r.message_id] + ); + continue; + } + + // Reactions are NOT chat bubbles — attach the emoji to the message it + // reacts to (message_reactions). An empty emoji removes the reaction. + if (r.message_type === 'reaction') { + const tgt = r.reaction?.targetMessageId; + if (tgt) { + if (r.reaction.emoji) { + await client.query( + `INSERT INTO coexistence.message_reactions + (wa_number, contact_number, target_message_id, direction, emoji, reactor, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (target_message_id, direction) + DO UPDATE SET emoji = EXCLUDED.emoji, reactor = EXCLUDED.reactor, updated_at = NOW()`, + [r.wa_number, r.contact_number, tgt, r.direction, r.reaction.emoji, r.reaction.from || null] + ); + } else { + await client.query( + `DELETE FROM coexistence.message_reactions WHERE target_message_id = $1 AND direction = $2`, + [tgt, r.direction] + ); + } + } + continue; + } + + // Upsert chat_history (ignore duplicates on message_id) + await client.query( + `INSERT INTO coexistence.chat_history + (message_id, phone_number_id, wa_number, contact_number, to_number, + direction, message_type, message_body, raw_payload, media_url, + media_mime_type, media_filename, status, timestamp, context_message_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (message_id) DO UPDATE SET + status = EXCLUDED.status, + raw_payload = EXCLUDED.raw_payload`, + [ + r.message_id, r.phone_number_id, r.wa_number, r.contact_number, r.to_number, + r.direction, r.message_type, r.message_body, r.raw_payload, r.media_url, + r.media_mime_type, r.media_filename || null, r.status, r.timestamp, + r.context_message_id || null, + ] + ); + + // Upsert the WhatsApp profile/push name into profile_name (NOT name). + // `name` is reserved for a name we explicitly captured (AI ask-name flow + // or manual save) so inbound messages don't clobber it — that clobbering + // is what made the automation "is the contact known?" condition always + // true. Display falls back to COALESCE(name, profile_name). + if (r.contact_number && r.wa_number && r.contact_name) { + await client.query( + `INSERT INTO coexistence.contacts (wa_number, contact_number, profile_name) + VALUES ($1, $2, $3) + ON CONFLICT (wa_number, contact_number) DO UPDATE SET + profile_name = EXCLUDED.profile_name, + updated_at = NOW()`, + [r.wa_number, r.contact_number, r.contact_name] + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + // Evaluate automation triggers + // 1. For incoming messages (keyword, anyMessage, newContact triggers) + // First: if this conversation has paused executions awaiting a reply, + // resume them and SKIP fresh trigger evaluation for that record + // (the customer is mid-conversation — see plan: "Resume only — skip + // new trigger"). + const incomingRecords = allRecords.filter(r => r.direction === 'incoming' && r.message_type !== 'status' && r.message_type !== 'reaction'); + if (incomingRecords.length > 0) { + for (const record of incomingRecords) { + try { + // ── BANDEJA DE IDEAS: comando del owner "idea: " ───── + // Va ANTES del canary Brain v2 (el owner está en la allowlist y el + // canary haría continue). Captura, persiste y manda tarjeta de ruteo. + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const ideaMatch = String(record.message_body || '').trim().match(/^idea\s*:\s*([\s\S]+)$/i); + if (ideaMatch && ideaMatch[1].trim()) { + await handleIa360OwnerIdeaCommand({ record, texto: ideaMatch[1].trim() }) + .catch(e => console.error('[ia360-ideas] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── EXPEDIENTE: comando del owner "qué sabes de " ── + // Mismo patrón que "idea:": va ANTES del canary Brain v2 (el owner + // está en la allowlist y el canary haría continue). Read-only sobre + // ia360_memory_facts/events; responde SIEMPRE (nunca queda mudo). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER) { + const memQuery = parseIa360OwnerMemoryQuery(record.message_body); + if (memQuery) { + await handleIa360OwnerMemoryQuery({ record, query: memQuery }) + .catch(e => console.error('[ia360-expediente] owner command error:', e.message)); + continue; // no procesar como mensaje normal + } + } + // ── CANARY Brain v2 (reversible, allowlist) ────────────────── + // Antes de TODO el pipeline del monolito: si el remitente esta en la + // allowlist y el flag esta on, el texto se enruta al Brain v2 y NO toca + // el monolito. Fire-and-forget (no bloquea el ACK 200 a Meta; el inbound + // ya quedo persistido arriba). Fuera de la allowlist => no-op total. + if (ia360BrainV2CanaryEligible(record)) { + handleBrainV2Canary(record).catch(e => console.error('[brain-v2-canary] fire-and-forget:', e.message)); + continue; + } + // ── PRODUCTION-HARDENING: CAPTURA DEL COMENTARIO del owner ────────── + // Va ANTES de TODO (paused-resume, flow, funnel): si el owner (Alek) tocó + // "Comentar" en una alerta de fallo, su SIGUIENTE texto ES el comentario. + // Gate barato y colisión-segura: solo owner + texto + flag presente (el flag + // solo existe en la ventana corta tras tocar "Comentar", así que aunque su + // número coincida con el del prospecto de prueba, no se confunde). + if (record.message_type === 'text' + && normalizePhone(record.contact_number) === IA360_OWNER_NUMBER + && String(record.message_body || '').trim()) { + try { + const { rows: awRows } = await pool.query( + `SELECT custom_fields->>'ia360_awaiting_comment_failure' AS fid + FROM coexistence.contacts + WHERE wa_number=$1 AND contact_number=$2 + LIMIT 1`, + [record.wa_number, record.contact_number] + ); + const awaitingFid = awRows[0]?.fid; + if (awaitingFid && String(awaitingFid).trim() !== '') { + const comment = String(record.message_body || '').trim(); + await pool.query( + `UPDATE coexistence.ia360_bot_failures + SET owner_comment=$1, status='comentado' + WHERE id=$2`, + [comment, String(awaitingFid).replace(/\D/g, '')] + ).catch(e => console.error('[ia360-failure] save comment:', e.message)); + // Limpia el flag. mergeContactIa360State NO borra llaves jsonb (solo + // concatena), así que lo vaciamos a '' y el check de arriba es `<> ''`. + await mergeContactIa360State({ + waNumber: record.wa_number, + contactNumber: record.contact_number, + customFields: { ia360_awaiting_comment_failure: '' }, + }).catch(e => console.error('[ia360-failure] clear awaiting:', e.message)); + await sendIa360DirectText({ record, toNumber: IA360_OWNER_NUMBER, label: 'owner_comment_fail_saved', body: 'Gracias, guardado para seguir mejorando.' }); + continue; // NO procesar como mensaje normal (ni paused, ni funnel, ni triggers) + } + } catch (capErr) { + console.error('[ia360-failure] comment-capture error:', capErr.message); + // si la captura falla, dejamos que el mensaje siga el flujo normal + } + } + + if (await handleIa360SharedContacts(record)) { + continue; // B-29: vCard capturada y owner-gated; no cae al embudo normal + } + + const { rows: pausedRows } = await pool.query( + `SELECT id FROM coexistence.automation_executions + WHERE wa_number=$1 AND contact_number=$2 + AND status='paused' AND expires_at>NOW() + ORDER BY paused_at`, + [record.wa_number, record.contact_number] + ); + if (pausedRows.length > 0) { + for (const p of pausedRows) { + try { + await resumeAutomation(pool, p.id, record); + } catch (resumeErr) { + console.error(`[webhook] Resume error for execution ${p.id}:`, resumeErr.message); + } + } + continue; // do not also fire fresh triggers + } + await handleIa360LiteInteractive(record); + // PASO 2 Revenue OS (calificación) — DEBE ir antes del agente genérico y + // gatearlo: si el contacto está en estado 'calificacion', este handler captura + // la señal, manda la propuesta (PASO 3) y CORTA el embudo (return true) para que + // el agente no responda el mismo texto ni empuje agenda (guardrail). El owner + // tiene deal vivo en P2, así que sin este gate el agente respondería en paralelo. + const revHandled = await handleRevenueOsFreeText(record).catch(e => { console.error('[revenue-os] dispatch:', e.message); return false; }); + // Free text (no button) inside an active funnel → AI agent (fire-and-forget; never blocks the Meta ack). + if (!revHandled) handleIa360FreeText(record).catch(e => console.error('[ia360-agent] fire-and-forget:', e.message)); + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Trigger evaluation error:', triggerErr.message); + } + } + } + + // 2. For status updates (messageRead, messageDelivered, messageSent triggers) + const statusRecords = allRecords.filter(r => r.message_type === 'status'); + if (statusRecords.length > 0) { + for (const record of statusRecords) { + try { + await evaluateTriggers(record); + } catch (triggerErr) { + console.error('[webhook] Status trigger evaluation error:', triggerErr.message); + } + } + } + + // Enqueue durable media downloads via BullMQ (concurrency-capped + retried) + for (const r of allRecords) { + if (MEDIA_TYPES.has(r.message_type) && r.media_url && r.message_id) { + await markPending(r.message_id); + enqueueMediaDownload(r.message_id).catch(() => {}); + } + } + + console.log(`[webhook] Stored ${allRecords.length} record(s)`); + res.status(200).json({ ok: true, stored: allRecords.length }); + } catch (err) { + console.error('[webhook] Error:', err.message); + // Always return 200 to n8n so it doesn't retry infinitely. Use a static + // message — err.message can carry internal Postgres/schema details. + res.status(200).json({ ok: false, error: 'Processing error' }); + } +}); + +/** + * GET /api/webhook/whatsapp + * Meta webhook verification endpoint (for direct Meta → ForgeChat webhooks). + * Not needed for n8n forwarding, but included for completeness. + */ +router.get('/webhook/whatsapp', async (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + let accepted = false; + if (mode === 'subscribe' && token) { + // 1) The per-account Webhook Verify Token set in the connection form. + try { + const { rows } = await pool.query( + `SELECT verify_token_encrypted FROM coexistence.whatsapp_accounts + WHERE verify_token_encrypted IS NOT NULL` + ); + for (const r of rows) { + if (safeEqual(decrypt(r.verify_token_encrypted), token)) { accepted = true; break; } + } + } catch (err) { + console.error('[webhook] verify-token lookup error:', err.message); + } + // 2) Backward-compatible env fallback. + if (!accepted && process.env.META_WEBHOOK_VERIFY_TOKEN && safeEqual(process.env.META_WEBHOOK_VERIFY_TOKEN, token)) { + accepted = true; + } + } + + if (accepted) { + console.log('[webhook] Meta verification accepted'); + // Echo the challenge as plain text (Meta sends a numeric token). Sending it + // as text/plain — not the res.send default of text/html — prevents the + // reflected value from being interpreted as HTML (reflected-XSS). + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + res.status(403).json({ error: 'Verification failed' }); +}); + +// ============================================================================ +// W6-EQUIPO-0 — Endpoint de callback n8n -> webhook.js (EGRESS UNICO) +// ---------------------------------------------------------------------------- +// La capa de agentes n8n NO habla con Meta/ForgeChat. Emite una DIRECTIVA +// (Direccion B del contrato W6 §4.3 / W6b §6.3) por este endpoint y webhook.js +// la ejecuta por sendQueue (unico chokepoint de egress). Auth = header secreto +// compartido X-IA360-Directive-Secret. Montado en la zona PUBLICA (sin +// authMiddleware), igual que /webhook/whatsapp y /ia360-intake. +// +// GATE EQUIPO-0 (CERO OUTBOUND): mientras IA360_DIRECTIVE_EGRESS !== 'on' corre +// en DRY-RUN: valida y ACK, pero NUNCA encola ni envia. El cableado de envio +// real (free_reply/send_template/owner_notify/handback_booking) se completa en +// EQUIPO-1 (PARCIAL). NO toca el ciclo de agenda VIVO (12/12 PASS). +// ============================================================================ +const IA360_DIRECTIVE_SECRET = process.env.IA360_DIRECTIVE_SECRET || ''; +const IA360_DIRECTIVE_EGRESS_ON = process.env.IA360_DIRECTIVE_EGRESS === 'on'; +const IA360_DIRECTIVE_ACTIONS = ['free_reply', 'send_template', 'handback_booking', 'owner_notify', 'noop']; + +function isIa360InternalAuthorized(req) { + const provided = req.get('X-IA360-Directive-Secret') || ''; + return Boolean(IA360_DIRECTIVE_SECRET && safeEqual(provided, IA360_DIRECTIVE_SECRET)); +} + +router.post('/internal/ia360-memory/lookup', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const record = { + wa_number: normalizePhone(b.wa_number || b.contact?.wa_number || ''), + contact_number: normalizePhone(b.contact_number || b.contact?.contact_number || b.contact?.wa_id || ''), + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const memory = await lookupIa360MemoryContext({ record, contact, limit: Math.min(parseInt(b.limit || '8', 10) || 8, 20) }); + return res.json({ + ok: true, + schema: 'ia360_memory_lookup.v1', + contact: { + masked_contact_number: maskIa360Number(record.contact_number), + name: contact?.name || '', + }, + facts: memory.facts, + events: memory.events, + guardrails: { + transcript_returned: false, + external_send_allowed: false, + }, + }); + } catch (err) { + console.error('[ia360-memory] lookup endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'lookup_failed' }); + } +}); + +// PASO 1 Revenue OS (Pipeline 5) — dispara la apertura para un contacto. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). Egress +// por el chokepoint único (enqueueIa360Template). Pensado para campaña/broadcast o +// siembra E2E staged. wa_number default = cuenta IA360 única. +router.post('/internal/ia360-revenue/opener', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contactNumber = normalizePhone(b.contact_number || b.contact?.contact_number || ''); + const name = String(b.name || b.contact?.name || '').trim(); + if (!waNumber || !contactNumber) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const result = await startRevenueOsOpener({ waNumber, contactNumber, name }); + return res.status(result.ok ? 200 : 502).json({ schema: 'ia360_revenue_opener.v1', ...result }); + } catch (err) { + console.error('[revenue-os] opener endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'opener_failed' }); + } +}); + +// OPENERS V2 — vista previa de un opener al WhatsApp del OWNER (nunca a un +// contacto). Renderiza el draft v2 (primer nombre + quien_intro opcional) y el +// interactive (botones/lista) tal como lo vería el contacto. Único egress: +// sendOwnerInteractive / sendIa360DirectText -> messageSender. Auth = +// X-IA360-Directive-Secret (mismo patrón que los demás endpoints internos). +router.post('/internal/ia360-openers/preview', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const sequenceId = String(b.sequence_id || '').trim().toLowerCase(); + const found = findIa360SequenceFlow(sequenceId); + if (!found) return res.status(422).json({ ok: false, error: 'unknown_sequence', sequence_id: sequenceId }); + const { sequence } = found; + const sampleName = ia360FirstNameFrom(String(b.name || 'Alek').trim() || 'Alek'); + const quienIntro = String(b.quien_intro || '').trim() || null; + const bodyText = typeof sequence.draft === 'function' ? sequence.draft({ name: sampleName, quienIntro }) : String(sequence.draft || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const synthetic = { + wa_number: waNumber, + contact_number: IA360_OWNER_NUMBER, + message_id: `opener-preview-${sequenceId}-${Date.now()}`, + message_type: 'text', + direction: 'incoming', + }; + const interactive = buildIa360OpenerInteractive({ sequence, bodyText }); + let sent; + if (interactive) { + // ownerBudget=false: la preview es una petición explícita del owner; no debe + // caer en el presupuesto anti-spam de notificaciones. + sent = await sendOwnerInteractive({ + record: synthetic, + label: `ia360_opener_preview_${sequenceId}`, + messageBody: `IA360 preview opener ${sequenceId}`, + interactive, + }); + } else { + sent = await sendIa360DirectText({ + record: synthetic, + toNumber: IA360_OWNER_NUMBER, + label: `ia360_opener_preview_${sequenceId}`, + body: bodyText, + }); + } + return res.status(sent ? 200 : 502).json({ + ok: Boolean(sent), + schema: 'ia360_opener_preview.v1', + sequence_id: sequenceId, + kind: interactive ? (interactive.type === 'list' ? 'list' : 'buttons') : 'text', + body_preview: bodyText, + }); + } catch (err) { + console.error('[ia360-openers] preview endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'preview_failed' }); + } +}); + +// BANDEJA DE IDEAS — captura desde el Brain v2 (intent idea_captura) u otros +// agentes. Inserta la idea y manda la tarjeta de ruteo al owner (único egress: +// sendOwnerInteractive -> messageSender). Auth = X-IA360-Directive-Secret. +router.post('/internal/ia360-ideas/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const texto = String(b.texto || b.text || '').trim(); + if (!texto) return res.status(422).json({ ok: false, error: 'texto_required' }); + const fuente = ['conversacion', 'agente'].includes(b.fuente) ? b.fuente : 'conversacion'; + const contactNumber = normalizePhone(b.contact_number || ''); + const waNumber = normalizePhone(b.wa_number || process.env.IA360_WA_NUMBER || '5213321594582'); + const contexto = (b.contexto && typeof b.contexto === 'object') ? b.contexto : {}; + const ideaId = await insertIa360Idea({ fuente, contactNumber, texto, contexto }); + const synthetic = { + wa_number: waNumber, + contact_number: contactNumber || IA360_OWNER_NUMBER, + message_id: `idea-capture-${ideaId}`, + message_type: 'text', + direction: 'incoming', + }; + const cardSent = await sendIa360IdeaCard({ record: synthetic, ideaId, texto, fuente, contactNumber }); + return res.status(200).json({ ok: true, schema: 'ia360_idea_capture.v1', idea_id: ideaId, card_sent: Boolean(cardSent) }); + } catch (err) { + console.error('[ia360-ideas] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'idea_capture_failed' }); + } +}); + +router.post('/internal/ia360-memory/capture', async (req, res) => { + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const b = req.body || {}; + const incoming = b.payload && typeof b.payload === 'object' ? b.payload : b; + if (incoming.schema && incoming.schema !== 'ia360_memory_event.v1') { + return res.status(422).json({ ok: false, error: 'unsupported_schema', expected: 'ia360_memory_event.v1' }); + } + const record = { + wa_number: normalizePhone(incoming.contact?.wa_number || b.wa_number || ''), + contact_number: normalizePhone(incoming.contact?.wa_id || incoming.contact?.contact_number || b.contact_number || ''), + contact_name: incoming.contact?.name || '', + message_id: incoming.source_message_id || incoming.request_id || `ia360-memory-${Date.now()}`, + message_body: '', + message_type: 'memory_event', + }; + if (!record.wa_number || !record.contact_number) { + return res.status(422).json({ ok: false, error: 'wa_number_and_contact_number_required' }); + } + const contact = await loadIa360ContactContext(record); + const signal = { + area: incoming.classification?.area || 'operacion_cliente', + label: incoming.classification?.area || 'operación cliente', + signalType: incoming.classification?.signal_type || 'senal_operativa', + summary: incoming.learning?.summary || 'Señal operativa capturada por IA360.', + businessImpact: incoming.learning?.business_impact || '', + missingData: incoming.learning?.missing_data || '', + nextAction: incoming.learning?.next_action || '', + affectedProcess: incoming.learning?.affected_process || incoming.classification?.area || 'operación cliente', + missingMetric: incoming.learning?.missing_metric || incoming.learning?.missing_data || '', + confidence: Number(incoming.classification?.confidence || 0.65), + shouldBeFact: Boolean(incoming.learning?.should_be_fact), + }; + const persisted = await persistIa360MemorySignals({ record, contact, signals: [signal] }); + return res.json({ + ok: true, + schema: 'ia360_memory_capture_result.v1', + dry_run: false, + ids: persisted.map(item => ({ event_id: item.eventId, fact_id: item.factId })), + guardrails: { + transcript_stored: false, + external_send_allowed: false, + crm_sync_status: 'dry_run_compact', + }, + }); + } catch (err) { + console.error('[ia360-memory] capture endpoint error:', err.message); + return res.status(500).json({ ok: false, error: 'capture_failed' }); + } +}); + +router.post('/internal/n8n-directive', async (req, res) => { + // 1) Auth por secreto compartido (timing-safe, reusa safeEqual del modulo) + if (!isIa360InternalAuthorized(req)) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + try { + const d = req.body || {}; + const action = String(d.action || ''); + const espoId = d.espo_id || null; + const payload = (d.payload && typeof d.payload === 'object') ? d.payload : {}; + const announce = (d.announce_handoff && typeof d.announce_handoff === 'object') ? d.announce_handoff : null; + + // 2) Validacion del contrato (Direccion B) + if (!IA360_DIRECTIVE_ACTIONS.includes(action)) { + return res.status(422).json({ ok: false, error: 'invalid_action', allowed: IA360_DIRECTIVE_ACTIONS }); + } + if (action === 'free_reply' && !(payload.texto && String(payload.texto).trim())) { + return res.status(422).json({ ok: false, error: 'free_reply_requires_payload_texto' }); + } + if (action === 'send_template' && !payload.template_name) { + return res.status(422).json({ ok: false, error: 'send_template_requires_template_name' }); + } + if (action !== 'noop' && !espoId) { + return res.status(422).json({ ok: false, error: 'espo_id_required' }); + } + + // 3) GATE EQUIPO-0: cero outbound. Dry-run = valida + ACK, no encola. + if (!IA360_DIRECTIVE_EGRESS_ON) { + return res.status(200).json({ + ok: true, + dry_run: true, + accepted: { + action, + espo_id: espoId, + announce_handoff: announce ? (announce.label_publico || true) : null, + requiere_confirmacion: payload.requiere_confirmacion !== false + }, + egress: 'suppressed (EQUIPO-0; activar con IA360_DIRECTIVE_EGRESS=on en EQUIPO-1)' + }); + } + + // 4) EQUIPO-1 (PARCIAL): cableado de egress real por sendQueue pendiente. + return res.status(501).json({ + ok: false, + error: 'egress_wiring_pending_equipo_1', + detail: 'IA360_DIRECTIVE_EGRESS=on pero el envio real se cablea en EQUIPO-1' + }); + } catch (err) { + console.error('[n8n-directive] error:', err && err.message); + return res.status(500).json({ ok: false, error: 'directive_failed', detail: err && err.message }); + } +}); + + +module.exports = { router }; diff --git a/frontend/public/ia360-bca/alek_presente.jpg b/frontend/public/ia360-bca/alek_presente.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8a83acf917ca0851c515a7dd23f87b1aad720465 GIT binary patch literal 68329 zcmb5Vby!s07dCtbMoQ43JEcov=njz@x{;6`Qd(jF5s^k3h9RU|k&*@}rD5n00cjEG z#&>*v@Av-y?U@UPIoHKmYp;8)``&Bs^LOs=4*;UBqNW0XKmY*3ynw%pfD!=4#{T!l zjC=RM_i*py;^N@oJ|G~#yAOFlObmGdc}PM=LqS4HMfwmz!AMC(OG{5rPfWqY!brzL zLq|{d@1KC~-Mfd2gG+>qOGHNkA))*KJ^p?Hi1Dy$z#p+eEC3cU2%8x6w-=xT04!_} zW&rmgxDYe3xW;7IC{D-D@pamGfk~TRDc$r zU4mT{NhDyaTk?tthf}CmfC^ia3L7R5xPrcB>Hu8;6BPhRYBFJs10aQPS{BhHS{5N< zmI5_ysw67raH?oQopBHgwJL~^rHEIJ8DP%WR0dMOS@?o_&&j$0<_Lr2J~flnLVSTZ zhjgwYB}GjZB$-20Ndb#(vM(1GIE7XZMh zg_8*YX-Qi-92?FbEWOW_!y4+D1T>j6!Clm<&x!M`nZvU1=#}l5Kmtm5aYf)Ld*(7F z19oM#C-UH;D$M;z?8>36-2f8^%GwtLhm5Bv|1*gNhOb7fr)*FNaW^PZWzC%XqJ$%0 zONFNnI$`ACrw2f66+Ll6*ZPj<>%zAwR*}pN$kXWQH5*-N(E~1ajw?=RWwAZ zRNZl!Ht!|`s00P-;rU-^?-4U$^#g>jN-(BT9(_rCr{1o2g?waKXKFc!7H-elDkd{2 zH1Z8FE50tRy$D@+q)G+=t6a>4wS)!&O3VWC5H4j>uqsP0HeS35yLt*S5tb7v7)VWi zA7St=g_s>*jXTy(B_fk`JoVp%VJ7QcYQdBLW-W=F4g6M#sz@N&)>+XOr-;y5KvA9$ z0$&iofog(E3N@)z1@fr~!vK)#P?vG$rFlu8P2*6|%~<)LlOvy??6$i+Y^!n8WBr}R z`Qw<7f?pT?9*t+gV|}4qe5;Bt(gjk5YjAu20`fPG9~djIJt+63E;Gph{P2J2!<-KQ;BG<)78M&C z3zf1pQ;|A1JH8NPJo25^9??NYK*bjyh&;)ObzmrWGOFu-60`jiPv?G z!%-z9qvlP6E%iA)!`jFM!XDN5wcJkD^Uv=lM%{kR*GS&qo-%T>-^ZqoT4W!;ud^f^ zzPjJx*OXVN{jdFYxTO2bPWXlPa=RtF&;}u79KZw!leIb+=fK?n zfCnPNg9G5A2(0|t2T7xCwkD1hnkKJ{kPw0tJ^Xx_9w4u*5&?ap&Z7B+@qs&D!W>97<)GB_v_nzbR%ux9wvW@WrHTwOxu&zbrQ@0p6G@V6;i?@|PX>4G z%If&$G0&PF;Y(irnF&!HKriid$@~RU$F%H|(l34Q-j1AwJT5dCMzyT>$QSZ@rG$+%^VS0?}_|?Mv>GyAuSoAT!^a0P@ zg^Tonw`0kQoPVikqIMWoWccV?{>AW`Qt2wY1uFb}Dkdy=RBGk;3CkUTeBx z!{eyBXf#z*G<>(JWS<01K zYHJ`5e+1SrI$N|eFx>1l`4J&|BD40Oo2?l+yFR-j>npEKdkPVr^UL4%0&kdTx&Umx z<>9pcKBZ4<=_c~5Dc~=xS$Ioi8Iit4!h8xzZiGDf?06?nHgj0C{1sNoLAl{@9RP_z z9&`00J96g$@{!Bm9hx|(`AN-mZe8$r3K*+wkdqx>Am%yMAX^-9eRtS`b(dG%t2h9M ztP4ln&)7G+c6+SN3cokCKTXOof1Svbyj14h=qu@MT(z)fgdrox#ley+pOwn#3glF zyA6oB%`I@ckEC&HNUDM{M3aIS54MNEi1joCsNZ4Z2v`%cV#8l+g2oAHUUa|Kbn;PW zX`Y#MW_Goq=oZn$;Zn2?BU0K}(ZATwWBz@)JG)KaN%~9ru5G!KdAkhBWSQ#C!;Y4Q)Le$A|+>vuC(G>TFEH2u%1nVnQAS~rx7_65u|{T%FtRp zMHs%I9Z@pRWmlq!xr*N4^qJ3x|R1m)Vt`JFtC< zcGeQg7Oa${+pqiF{eiBe(p+$@Xo@-J-9HeKb4J}uQ4+$K?Ir-PikQIQsq1}-C^8NQ zj0wXlks^yEmS170R07Rd_X2Q!pOTl|#Y*etft4J`!p&b`hk>#D*z(~-8>OYG&sv71 z46F^YbRF2KcC2mHgI+4E4CVhW7JX8~aQHNTIzhU#;qqXc(dv~&fy_%&579?TsW47< zKpu!p1u#UyjtAhdv6g``tRJQ;0=6TjQNTEbxy)_Dtsi&wO{dzCxsCzq(ZwFO>~3LU z7{uCD)Vu?fiIkWgBpi+z!zE5m1z`q0Xz4EDW@vYbXePA3pGXkN=3q_soCF>w09fC9 zmz&M)yIWNSVcp;T{9{zNjNPEvK~GG;&eE9GnjrBo0)PnsXdoCO0Z77FKzeXcg*?V5CM0-V90`x7IOQ6wMD1B>1x^&^Onu&1 zA=um>8{gVw5;kfJcQqM-m5{lVlxa+6a4m&7di^g<$(UbzL+MRXui_h#jTkWxSnFd^ ztDibeKD5*{k?6+!;$y$gwU;Uv=2FqQhrt=%Bi6G2p^XU) zu!mvXp~%PN2cA5>I@3KSCKwZ5qk+?JPXD@?g}8%Ixyo<0mX1E3ySDV;%Ht{WVKGjr zg{(BzXiUX*VRGtDCvsLOMMC#6pvy;x5mFZ58*64dwAL7HBz?377L54hC6hDlpO(o1vBZ6Okm}N7JHI4Txae4FZ6FI!18}0m}^?rIg zjpS6R;BHmy*2U%Q+{8vpn~dKUn(^^?(9X8!32&2k@nNXtLt>P6H}vqje@sY3q7l3OxUG z$4=0C@jD>TO6GGti;9JO1SDA{`%{!tmH2YYn8?cUcR!EC0D|k-<7$S#K$=)z!@Rdg z(Z;#N`3`@-T*kRXhxxKs;Gg5WvB$%RzrdqCDOuZ-eJ?gzk(dTMwiDUPtw7Jkoq?EN z>l2GP0?1EZWqFJZgUFIy&F=%kTJ4T8zO@?JFnS*qJj27qcnbb87Wj$x5^63{3X$Ni z@v=nxYUV}b#&o>J(5~%N-LpSzH{Vfey1r^}Lt-+r4GI&YML3bbKSE+pNPtnddc{xV zHpCW=E@r1!Qx`hWUkE_z@)hwkWZ&{O&-31Utu2|N`LEzB2on56dSC}1f)e6Oh@?#L zx~L#H_aBPUAQ-4;3emAlRki&E{?i67ECwi6(IPfhBTZGs@iEI@n}n|nej#s(*BL(@ z$Nq9#_3iLq9=ji$Sh;1G-MqZNdHMY>;Oj}0R2$xEU5moH_A-6!xNw86O}M+OIPpH| z(AG$Sh6jAiNi|vy4yhiYsoT|JX>6{^MP%~(t>)I+Y#*i*ru$E{n4FIWUY=!bltQg6 z>Wvm=B_xwJRV*W`W(-jbGh6{X1N7j@=zJ{Yd#vNkDxXt!1_>qi7^+&lHO>EQG~wJ( zM~lQWse=`uc;xl-DY3K|*#xpftDwc=J#v`@C+ffGI(Ml6eKd*hCI%1IKM5pSL8lB; z!fPM76B%j_K6=mXY8u7s`EJ2ZjhX13>m!RVj>ZKh4fV$|1g>K*k6M~64Sj6g621vWEpWRXK?f6TUxCd+{lo+e9p7)*>akl zji4^6s@n710&|!?_J3e@o_io!kXSSnmKJY{!bIHrj+YC9lQ3+j zMtP=nHfayX{*&YH6^WtbgH7IC6Fm65zMVKXxccM~b-oIcBqjEZ4#_*5SvGNDpfD_) zcZ!dN90NpZ(tA_a*;3b8^paC?n8RsEJlW>fR&sN9eok*);o-~MV1Wn#iYX2kFQf_^-farQ#}PYG>BTs|}ObCQ{MLvAh~9xOduQ^;Xu zBUJ>{irqxbF_L@%Q(G!{?9I0Sp|WBj*5W8V>iFWUQ~#-yi+C~R z+4PII>Ml9_{q|k{FES}^G`~<7+vI=)6aE4*XE!nv=3cf{3hI~@R2TrrV=)0Bpuh?A zz_*o-CIYE*PaY2hA_mboCp!^8Dvb5hT6!^HSZb?38Qb~LUuM%XGXXLmI(n%EdK*0mQKfVrY7%72JG9;v$JrBtQYi9Srlmg60#j zhbaQD!1-ihknyl2EbufFx%0?W+ou+*Al|Ewn~N1DS*7kWbtWvcH0%*5Lx6QW%xG=i z4MP{6@6>tSz)P$?J1q&Qqo}P(MO!CY#m=1Dha2tU+aZCucl zhQ>*rGYr!*gsCDqVOYExxhQ%GbIq!~$*M6eS_#@0#dUQIygJ_HP-3{A6RCQ^q$pA} zQ(cHw)5aEFnQA&!aC5zps!dNDOZ%i+NciHxLE^#B0BA>tyRyH5Pt(hG3V$IV-}wzu z=k>w8mXhXF_n(d-v9*KqH9?qi_H4h2zPu)&>T!-RzprNPEXx2+-3zLhgh~IG@y6o3DdUX7lzvgVNov7{@wNhB|)EK6Jvkg9U)V1OP^&$m3xIBp!q_ z3?q#1u|l3i@f3|%P>z1TuO8%pp#@hwCrGRWB5lS>X~1zHdc6h9Ys*t5$> z*=o6oJ3Cnru&6;0(Ou7PFTYiEZK9tp8J=M~F0h}hcv4ehX7r8tU6F|9bIv3-k`iOi z?CNv~K4)==@zf=C6(U%MC%0+h4sp;N=nLwZ!?ZxdK#$}B&M*w%u#%D$dGQ9Bu=2qG zo*Lc)SJi!P_)b9D#?GjiWZUx?fPx7;bXGDzq>)CkYnVSkQnk%nr%gifRI_2-B}d+&h)EHQ z1un9OC@OqW6B43T{MJ{5GHarM^{dScRWe*|V$-vZ3$h(E+^_z+zTH^8czODxr_!Nz zVq)`}dik>E)2O3d(0&j375Dz;h^E(-w59Mer zC^h&$78^9Ja+5v*s4{Q13XZ5((t>BVPIq1xY$`RG28my5l`MT6(2^0UxpSip{v})% z(5!^sm@brA7I#HQn)R>efVXwune(K@8XUB9q1|JV?j3tE{EbG6elYGpcDum&)ER1u zDO>ZTa2Y&)yuF%dAGZ&M(ea6d7eNS0?_sy~ofEu~#~L0|&11!kT9sX~ZVXSPvpc=X!BbMy#RrcwL8>Rq zc86?WHboN@NVO6%Y94sP#&X-JruW>EW#G4xFl@-D%h?H8?VQ6$e8EAEi#*-<2UB~ia8y#qTtR-Z03&22gN$nlsQ;a6)#C))Qy#vtf=0H zQl1ZmDz&A42;`Ri>9Qx!{bZp=3PX#i0^^v|kb9#0l6{!)8obMT<^ zqC|k4#P!qBi|gR{z`Eo8(9q$N+Sw`n!PY#t9RA?rn#KgnJK4miCyeiIr6zuU$Mp~o zv%!S`WLU~rK;%EN0Ky?cJ~2=(UI7A67LA89;nOK&VvEJcy7rCO z4rR(gq1KlS(Tp_(;uJR8jLUvpwih?_B$m2mHo;6-bq5dZXM!_?JG%~gl1|2C&hwr| z{sl5_#WrUd`3plV+l-JWF0`T>^EF0G^-GjrRi}1J!Be$n30DpoqtQru(n|Yh{Y=Tq zY6Zod5zAh0#pfT}u^C@IdlmSWK9u`x{*+fW%3aEH(x|Uml~ll-Vf6 zsFghZ)cSf-Ek1eTe^xV(OZprU!6>`2+x_$W*|9~!%nH7SLmNFd+nJici*^;5_Vbm? zgVxiF$Cr*%clzkJshhrnzU%3?6duXbDns!((&*0<*)oQblj-{98T08z&ba}_$0hdY zlBve8>LpBe&na4x-fuaiK;u>5BY}zLn=+5Hw}U%><`v0FZUxW!1=;(3Z;~Bfc3VP7 zdWNST?cmWPbNAB5dk1f(-Imh&jiRvfD50P(+s7*;UQ6pipMn2@F1^% zS6EIiweHP-j8!FK%Tm%?s($jfNbuBfQ2RYug&t0S{sa@i2t^E(r%;ME_)t!~OGl~l zf#VZ6m(tF5j)^^Q9al3U#Sv#X_Q)BBt01h!RN zC(!xPPDI>#Zl%6Y|60={_5yKoqb;=|kk?c$rtj8e_Ax;!{`?AbBY7Lh|YGXK4r`T#jo*0PoN z-f(f?DeBbcWOb{#{VyQ1d!!pQzZf~%nY~zpP_OSpGUJuQ%7!hu-kZHnQD1d)q2>OJ zmPDEuk;HrPayW0M$Bow;+Rc`n-bu~!hJmn*BJd!JfILzc z1W}#Dh#_<1pL;r}+HySz3yY`S602yq7Oz(<>nd|SiKm|!MV0)Gb|dAg2qJsq=Gxn1 z(;^{nEc;re^&50W;25l+HF22tl%I>YaZ5(r5dP6icmzf?F*l{e~k5kETC& zTZvVe{?L!aM}PhT=uQgco$=d`uDfOp1U~tlmG7tCIVc zWbhX!`uS*tuobnSgM0xcDTPea8RGOBil?VTUqVGU+vl=x0~zxvmYt&ilP0HhPo&CSa#x>7mr_@<6MM zRG@f}XVA6uZJh<5sn5`d;;!YK(-}(Iby$*}-lJ0K2B_)SgxO7AmA@Qa(@t=xc1B_MG!?jCjen}B2$!?e^L%%0^{e$Mq!TBu!oa`F@v9h{vGlW z#_Zf;fv~Z_I9UIV0$~owfUp2;VlWBmJ!S|Qiy#h@fRMb-BVlrDR;a=YMKhZ3`8`rhI8w@`#-;p(~Uk9{Ib0AqAZ`9F@TeTH!8%4OcUpsE9F7nf!wvqHVp4 z_tKfr!P?bmLv-L93k}#`pj&fqg_v$0?vF!AobbcVOXxj9?n>&*N4nu3iA3y8y7?iA z(|IOx7aYBeB;a&et#A8)0P`3={L5fTU6_C;);U+?BJir+aP*Czi0>R-=pPcr#Hhe) zQ`s*3uwDtI1*s`%>Ce!(*QINrahO9E#49(AErg<5yeQqm(FZhUUas-_iG54L^SbX}_$5YBbctwh3)I zzk(N66G^0eVR*AndtXD|*djx%EP{pvs!d}V35T;%gdEnzsM|}^Yw(!W9nDo!%@PKaVg)z}u-rhmzLW+$R6a^Pge-ZtKgK!lk z9Qh*aiz7aY-IKZ(9bZa~a&Q%fXKOAv5O!zEQeDmc*%PCYG&_&8{-YmVDvp#oEr^vB**Qn_r2NNpXi}$B#yTe9yy1QvwZuEAhT7$!MwvnspM+K8Hnzg>3U`5Y~ z+qZ3JS+BomoWiRYp^e%vOL1kf9?}i>&;%?sfE$31;eT2S<_F86zTLlITv*Io&kx_yz3>uO#XfSn5Cmq7ya7@cJ z3xIFL9rXv;N;l_Plwxb)p!mcsJ*C&+{b*ZQyl7VaNQs*;DXIPoH2m~daUW6G<(Jb& zrj?^|{@$-5lg4U_CqFdJ#=Jsu>^Qv=-ZH>jsYm726WmnJrhl^_NU!FWM`RE>6jHA5 zxo_6``WN-2=$~1V6YoyePu<1% z-@p7l4(Im$3(yx&)fB2+IrOu{<))}EP<~lTTYSAqorpxk*KZWkXo{wuHhJw-(q4dS zCW*e;Qr{V?X~NtBqbJcr@aDKpVWhVfe4*=G(~@=E)dzw~KC*#{!@3PGtbHHy%_#sA|x+N)u^+)(DxWA}u{l4{8})dD=S zIqpgR)96)cqo zE|rqb28LQCyWG|>M^__aCukDm@4zG02J?p&hg?%nCS~40S1Db~Kl-NKPhOUp5_obHgqdk<%G>;tc z9=`r;GLylq7CrZP!OAvrn8oQX%7%kSOJ5>x6F%%Hi0b#I9(I)8LLKLNj|PlMY>A`R zyrcgIJbToTM7meihT35AkXQ)S$LMBDkx}qG@#I^-Yd$V7T@7 z#%j^w5z5Z}^pE$_)_{DYd+}@A*{PQUrfA5nMQ8NPH}t6a zSq~vm7_8hbknj-$u!hAT>2~X`ov-orHCt^O6#hcElQ8IUeJenQ2!RCu;A1dK&3TvBps{ zcS?R}L_{x|ck(dypt0oq&kp^s_FA*~Z0MN-S9YO)v`AnA=fWAE<@@E=1B=iZT@h?8 zZL(u&Sc$F8$#j2SFMi@8c2tIgxp7R+>MZ>BFJQjIxWPH=bL*ay2%kv28By>5wczr? zrBmP;;pVT$q!o69fkBJ7Dlc;w+YtXRo4EAI(=Wnr1l?QjL&IY<6EHxyab`4x~V*m`;fp9%3omMlDpQj5X@(e=hqv z1s_1}T+X<(w~_mk>N3{qW_^UkiubO)!MlVvk?gjCx<9%zc21eq8&3y*;cMYj$=0p> z><)w3)8(#ye3Ig$)t^CK^_#bZn7?Qwp%H#kq;0>O;ietwLud99k{QP92!sp>3$wcm zCkI-Jyu9SD6IwWb&!-n<39-^G9v`S?=6EV$x_k5rJY9Gc zw9Fi5U)+YdmCYZO1S9hA%;v0nZlaGSo72N6nLhYB-nAqalX|xV`uzxTzWx%Fd%O_( zL*_eE{1Ib@dD+_2I3bD-+JU+nhYzq0bKR;<2U20^Rf2?8lQq3I=(HJiS6DfRy!G_G z&?rUL=e!f%I%lrM-V}O*s71?)$j-ETuj*Sbe3oO=t~Ym3e1&hs{~{Ijq|VlxC@k{o zbjmedPk)cAds#RoPTPB5?lk|USy0abD!3&_9$np-P2I*@CH}sZ+RMS2+lW$6k8~Av z&s8hryCbo$DOcrzd*zGmWr?ef#(J8-tfr7dE8+}6TGv`>XSeNj7nRf5Q{mH-QPEvG zx0~Rb4>mI*^tyVcm0}&w-*&(CMI81&X&_v*a&a2A<5@?+#swjQNFs9NV&GGa(xM%U zrr<6IPZiCOc`M=>qxpnZ&FaR+_rY9(kux_%=N_ShL3=e8Q-w=sBVihp%;sJfe}RJq z?-rqf(uOn*u3smLandpyW-k1a3<-6m-=)X2XAZQHNk4i#ouh6)QT4!O8~#}REIW&N zG5c^x+wc4)wn5DR;g&)A{lUtk1hK43!Dv+fv)IaXc3Wd2iSTI4>~=D@*ll|LmCH0y zbmmrltDbmBN_+4C8*8 z`kvZ&Dogzbhb+4|X~r!xrhrBHnT8h6&d-{fLgYD}?vxY{4g>cKgWq8JV21XI43A&4 zngr{%az#z$EOeqperf)=wKqAO;N7>OUz&7Md#1pWn}XFVLTCpES?lK+e}REk&?{9V z+b&W(B07F^t%==5(LRz|d}L&w8T1apH6n=M%@EBgpP98D4F-k{c|f_xi`}VEpT(nM zQw(>^h{%~5drF23d?{LT``|HRsL)sb@iwrEu;uk$$e!*m#Omn;muw{t#pd}s z>=V2tdig^s3 zH|pq3JB11}r_pu=4apu5?Ei7@jyGb7OPZ6(>5EXRI&eRflFDh5mWxO;(j}lQ!JncQu5sU`s;MM7 zv=55j@g+kF8NA$daL+EuHkzUlSbMz8d)=l)d=I_k)>nOYp?_v|VD4_7L8SBLnO899 zGoF&mE~a0P=}+#?Z(Cjt60Vs*@IB^B&lZ>O(5Kt7m7$+j?C+XOuTL3+J$7$$9wmC;~@AcO3 z@WdWXcnu$Rz2CyaHp3;e0%;d2Bk{T=$$cgVriCWD2BKxOk8BzTJLflscOD0uEEb=a zgjzy_?v^Fex9F-R%qVT4D{hUhVp&vl<5w-$fcHsh|t& z6V&06uE<{z*jTR^Mmh*y82q%HV<1a3tZoPhf`!&Qs{)3%LG0ZnhsVvBuKx zO>-+RYyFz0D=~(Q-1^>rO8hm2!1Ud;fBsA1GlZyhp?<}N>xJWhK>taa>s$KhldV63 zxK1)`#cRel2EPWYUxp# z;`;J!nA+dB$8iX{a#6=pHKxWbCjNKdG0c5av~Q7#9b%^;S{VmZe*s7IjEh9|S=Qm$ ztEkI_X7Y}HSpu*3t#{ajVT)-QzKw{BliSeSiN)Hx>QH~UeNN|-rz@c=jthCS(8B4^ zGYz>9M?Rf?rPs0<22+<7^gN&1E*!fC^8{N-x9!||bTU&-+g*RT$fo>$_*_^_bNE)e zXTC4*IJ43sQG2Mi_9XQ50o%il&dR(=cyGv=xl9Gp^!ZR?PtqsvZnZeLl(cHqzs=JqH_d(+`P{9zf1_gboWqT6PV|a; zfCavPP(_mdv;1MDMBP%^n=d1muXi>DBb}~KHT@wssiIUA1-%M3hm{Qt1bekiXb0Jx zx*xU-T_eK#4TzBdj6&~eu0vbs_y;Avyo+IbH2r+&PAld2zj>Z)KA#)t2xeT=_L(Y~ zhoN=?kyX`~yG9FccT#+J>d?oF3k$bKr6sbZY>;|vMh#hZ>G3?wa|M6o_K8e6>Ky7i8ZtRrXXuBz~;igP@h`{3eHE8IyK} zw)d)gF@=mGkVus8v}2#I)V-owrc~R#*f!^Mr)t?U{;Y03mXoC-zB-^H>Dlnubniz$ zH2e4R)!g~3NV%fglTD+bod>&emYoA;SIC}+1+5@bzg)u> zuw`c8_`KwECc124XtrSPYZ$Aq;m^`}*pYK)IfGQW)6k$-nhb9kO9&}peil*bUMC#9 z6yvP<=~M~qAX$d{wfD2-)jMt*!iIs)4Y=(7)3aN8c*(8#PE>4J z%WJJ_{x{ev`C(pcQFO*G@Z8-DW6NwkCj>0`v28qz?QEq|2F;{{QYXZ5aKJuIJUcWN zr=Sr4u3GYTgG4K+B7IgcRkQlZlEoX<*PgWnSCI2m_N^&d%`$7jZ#UH(9T+sf878M` ze-%GOR5r1#Xl&@7t6NpG?PC6D+(^d!i_{LoRfZALP3sZY#B!BI<1>HJEC*`=Dni_i zu}LQUMpt<4=C{OhjAElro}m1=Q%Ivd!<{Zuq37Q;SJoO|{r&NwLC`xP*OLz$+qb8T zizkw;vZ~MTRZ<^QbG`Jb!G>SYmUTpz?n0M&%OKk{G9JY)#3Gfjw%E$5V&*23NNiA8 zm$k3}V{sng7qri^*vN`;<$RNN=`ZfIxE!Yb?9*qiic@NXQ`Ajqw2t3LwoMi7zF!Iz zC(L^cOP^enPkBKxM7>1zT3pe zo`?fo<5YwYc?_={W6Jly?C$cy`wrv1wDZ)nQq1!L9a5(5_-rvXb0eR_c=Wss{V7?B z!t9#oIl#~znY_DvbXr}PYV z?d2A#GYN4pt_@>+6PA_8`ZIafoAYQIb2V-l_M111a8ZJOA8moKt?J zwHv6mXv#9_@>>cOetJ-LHSsWhZ14STRZ*3=zjylzL(!5I9PvXZ(g=w{`mK0 zu&_wuFZAqhNM<9qGUB+TLyn3%Q+pN{H`%M_d2UIp0Y3$4>z9^Vz9w0~L2%t_bYkc) zP`9}!wyy{h<$AM4i&yk}au>T&jBCnPtE4U7GL3*utbB`mIf|X(*kI+zW$M>kodZhG zFnMbMC=uDei5C{e01Ac{7M4@`-)GGW>M8e!Ue1Epq~zW_!m5i_Cxb#Sd)H0hK<2=X z99>v8Mi6uNV3{rF>4f#m1&PqJn`K3G*%XsEPR=is$fxnv$DL1kc%ZWp78*P;18x@a zfjcrop;v_2GROEGsTnYMgA#$VvNv}yv(RCQuK$`vim~KB zyX42AIOf*nxJM!{O-k79-NeiowH-y|N|qcn&m)L`q>{>r+~<3|24IkJJgq@9rPnCB zb?N(X09Ippo|(eWQQipz`8D@?A*|%15(8Kxb)=-O zi-LlpBXxF*qOfq{AEd&jfz!O+E>nvznF90XQ@PXnCi3*@FI(3Se)+gW-KtDlmyNu7 z{t<83{12If(5;W&hM|+WnffEkOX=3ao9po$=5Uw<&9IoTFz&NU3m%g`aG)9$7F~Prt*kD6PUdKM zx@J1mGDSUm>n||v*jpV}@>{BVY=?4XLM$J(Cer)G`$E;WX-1jf&?ur2{rt)T*4Ogh z)Tx0Je_wa$X04U)M79{#FbnV7UGVOlWO%mRjK|*>O@9c4^O}I~V~u>|#pqDcsxMKR z!{1{1dHP;H9PeEel}~Y9WM~d8)ik1#ImwqFWNMB#h^!`7iwbbJ-Kq9!7y9k?`063) zCMw zP3@IJO{H6$j|TU$Mm>YhO0mc)&IHZ*Grn!nokUM>A6+$(3Q*N5W8pD{yS-&`&u#-V z^2??>I648DriJBhQ@(E0la`KmH3qXG91<+=8|lC$<7G{PG2Su>$$tUCr&p!sp=y5FntJ}wSBT;;?kjxzq z&#;g0*j~PA>aDYx%#7;qF;UMCe#1o1c5@n9eo8S~gTG*xSuS_h2N9j){#FNehH5@l zLG$O8UIZ)8&3tcN_j4-16Xs#(>g5o8&u%wz{9je~!92m;q)HudXp}NF9{%70UyvNB zuTulKiWo0c%rA4BoV1NiKc`qP-h~e9zgwq> z$o2$38CLG)i*iNP1%H*5$o}EP;E|1CVFnD{4-fwBqR&-YI*`36V_r<15R*gI6;IwZt)tpngY{ubad+1c+=@FCcL?qd#a)XQCpf{axVw9CcWDXk6hBxgP@rGV zx%d9o`X+DkN3z$vSvxyp&oi?x(<=pwQsk!T$LSP=pPHOOe1%m?;!jOXiY~Hd=f#@nox`cO3C1<(Qu^Ixso{A+% z1|BZ*w*3^F9}M&>#{$;clb4!eQJmH6LV1Fru=6j9vBSd!Kfu6%JRxpr+aDE3BsiP&nyK@H_!73zqxFybvwZ343RTb|yc!i$b$zV7M2sYC=$txfAH}b+1kle+#@k=5sKZSf+J_rq?1zDYp@U_|ELUbQl)*?O0 zx?*6bl!s^6Q)HBi^kzQhN!Lqe$2HUbXy!k(w5b5+-l}T3W0M>|Ijbhyia^+{Fq3Wa z%bKaMB$|S32T1yJR6ez~Tybmp-ciPXkn4X)yw_I~MtIpWgf2bXQ`UngYNw$7%(?}g zdR1Sgei_gAAODS@WbCMrLysRBKDi61zqTJA-b166DEQP;GP%^CNn!__6M=|owxl?0 zP=<*`jl2rg#=5qgEJ;`tay`OjE7Jn&ZsE1fwm(+6V;7xHqc{Q~`W}EA5YVeJ|d2;=C3G!|)!+=?wF4twcG?tCX3@3!2cM3DB z_bd$=OV0s30w0$H_2R6FKCzD4_wb0wnTsZpGpvkML1hC7e+)>UIyM^=TA;PLn|>nw zda9a4ESYVZ)4jV+3pW(UU-<62H%9|^HG_Q~(xsDpUva8wu7eJpPq{K-V*1C`p0 zl+IP^F1x()#TPyr4x5OMVPa3JKRYtHuL#368)+@9Wy1_O6*^5bd$Ix1)fy3eDS=P! zHR#aqlkLDDbasJawx#-X$KmWk@h7K!+@-kb0n1`2;im~rr#w$5-I2q*RB=k05Q)Vz zO89)^A6~O^;3k9zFu}f|6{)IfP`7EWT!K1C=c}Lmk5BxCb6QqE@^t>fRh+eIpA(xb zxGWE$WM}7=pESH5wa*(kvNY**iC>EIKp(iK#(1=3j?%vK%MH!6W9b%9ZK5r4+Q+xv z*aNq_4;!!X&o*LXC4Ma^og#5~M#|LSqsBU`%+UOW=(q3L- zG_h@MeIBu#>msZwxV-gNR&(kf1pBh+CsEDO`0MWlWSi-eNigKD^`I9EqRNbH$9XNu zgZ6suoA{L@CiZxJ`dr=s_UmTRAQsUeb}$zhyzYYUmm4t;7EJiWD8J(Wt!~}F4ZY^k zvLmNHu4ZX2OE-f5JD+gh;HsAix~N(jMoqak8jF_Dn3rq6>RUxHB>!XoUN@Dae}KIa z$qi;q#)#9@jG3b*?RIn%k$GKeQ+8<2pJ{x+luF<#>o@vx_MfO#=6WC--?2K5j1!+| zL`ihN_OJeCG=`;r6_dQ6avy81}rBdhSSP)9O~jii^K+Fn$v| zF5JIO4~Pi=(wkrmDwx2A7~$gJ(Q@JcOKm~`NNPxd5H$(RE!_TNHo-_x67T-P&A7fn zuBT>dn_TbxL0#4cq`2v%IH!zF^vs9j{{6h#aI5%nuw42 zZ$(MdCPz@@gF$KDLuqVH&H(<$A;TQJzIpd6g6@t(mvK+Lj%ZENSoi4zf1S^mgv@K3 z73-qhv<7xO?qkC{qyX+S-qE<R>Yl{*pRi2B_Xc+D34|B)%4ROL=W(W+2k^2#g#R`&>w18*D5|IwNgb4mYM<#`gJ>pLa5qyvu@+ zHC6hh(3G|1e`p&Lu-xE8H=PL#5gIFKYf>9Daixqs-veU`fb*dqLi)1{FWq=PEV`~G zC1REPn1NrvwkyUoXaUt8)cE}vEeZ9@3;4!ktp;~TBda7NP(NRE+!AZ61h7qxAO;#2+S`ZFFp$k=v*#8^hAJ z6*s0siR1~;*ruNI`YQ!a$Z#2|3)r9YOnrHd0ji(mLUe5k1udD1ANT;i=|eM3d_ANX zF%zkoG#{-JOHbw0MqUa3plYW1veoi8wezztDfod(m>u^;GT5Ryi{vkyOg<}(=*_Wu zh%YI2cnG_w?ZDG>x+}N-LK}|d9%*LNpVIG8OJ}6@S8oqVLTP5IY`dXvET*@ZP;Owx zO`1MTJPzWe_==9)MY=}Fm>q8uXW?=5tf5C+Sjx>*L&>?nxd}T^KR(!u4UuSKpjDpG zL$f_g=+q+KsmtS4IZbblU||vWyzu@yrz@>PPc;6PM`0H5;m>h-RCe7Q8G(kG=WYf@ z;Oa?nB2+6qeia%OZ6zYV zcW9Tp`Ba=#@iHpyuSN9^1b64gD!$W^^O#aziRb*90wx^yCO8AsE5!XLh#)V%n=C@!W6 zuun?qnVu@9ZViCBl;;1q z5%~XgBdo97)10q#c-ju1$^+w9W}Wo=q4|G!?Rj&Qcym*XfYMXlEu$QJQ`fL z?4{f&JeCG(iVIOwBw#GevMo(9)w8KAN>8Jinf}T!u*sOV$#7unt;@--%E_+FIShn| zD^dfKAo3IQDFt-ma)Hqku;nP%_DYtaEL3d3wuY?!OIt$@lE9FvSh{2n+l#60T&VxO z50EKc_At5M#O7bE1~xefawQs-&C?WB1{5;4Bk8@#bs4v^&3y9VgJ$I_u0KY-5@Z;I zQGY`|74_gQR+`v`^yPWWsKxXHZw9>I4We8d6SoNdsF+iXBgkp3%nJ$+OHYvd~Q8Zcul zhGBUze9nkYVfa&8ipqN+oA?)G$_S4&{5+JE4(*_}$)cL>Ls3<_)a*#@_Cq9O_Q*gF zK?KNF*2JTS{Np}mtm~#o>qFyxaqI@u;UhKrE|}Iv+^p_8!5=R+klSGGvw;Xa8VH;0KhG>X@-UIUQTbGsrp4Xr~4|X>0-dD`U}D{;q9W7lpR&a`m%QzinWBR|iQYg* zw1yrqkguNC=#6lK<}7=wF9&WoXQ`kZ^lw3O0J zc{ze_{dzGk7b;O2_ctPwhhs78KAaNO1p!tpbgXoz+(AE?7q$`?Y9mhX{=2k`V`T~c zz5Zlj>Jm+^!f&MvIOR-!mXDH=ku@dz^!@L@fcIv)ciWvHUdZ9TVtYI{Iu(07$#R-K z!-(yh&^UjK6qp>0A@fT5yBo~*O`9>K(w&T{HZxRoFLqy^ihHYyC{8-Z1iZcWJ9m9i zhOtyXmPFe!g;_-F<7s~x+zJ>S5An3;WQ%u!a*{U z<<+n4JvYe~N2)CYh93|y21dlIa}0OZd;I3;Al*lqLvC+4^n2+wS?iT!Sfj&Vf8&4$ z#Ws?zb))k2^9l9^(>>D(eS~ZlrA_6FF;ZfXiC2o^qW{8qhNwSMZqHuSm*vNR-BSx> zQ2-j+Zv+ipSO#YUBVk$wiEtxs$wqe}p(Yn!$97HaUL=PL4dslRo71^$mYEMS%ViOy z2JFQ$AR(m^12U;_?rINC@Lu}kf8lnPz6$x;c<90;T~g(lQHv?Ek`G79-i88`*sfr{ z;St3g=F29*nx7FF1t&C&T&AYh4A6WiEwk&!xBkMF(#`n$k=NAJT&@}XD+;$SdRT`8 zYiH5yR}9qiK3^;Ot*RvJyX<_kyf#h#pO`sJ{hw?I==~?R>W%>}AExro^M6C@i1Mv& zqj-9t{R>oP%cs;6rC8T+m8MqoUfb>DK##gTJ9{!Q^R)RNagI0E^VMHd52pPtsQ{9^$GV~9N zj!)W8%|dpp(vpWDdd2|n|0h8`PDVZtJ>9ap9Lewb$emR{%mUtO{zf+RKA8w!LWZ&2 z-DPQKCa3zH;{~gSp2eefyZiV}eT6R{%&}|&@v&tD)s2hv1mClhm}R=5?~7~;QIfn@ zI0pHoM%=m7DA(7PG7{^v#*2zOh@Efxb4G(}y*o7s-M_!VJ!E+LG#mv89;3?c@8O(P z8z}BIDQLhGx>F$2#N^&-a(&pd*n7XZUwvulS0syavb0*TQb9O)^-DH+f5^|^L;C=# z%KcwBIE#HvW7+`by&}EX_h31#>_NRxkPx&ihdMix67g6<0w&fbjc29kwt*tlezQPT zHPM{7pwuah70d=@gOFV`wGFUG>O^)D-D*}Gxo`!TK8Q?X})xh#kO2vnEPn{m{G!qQQu z%AejAF9gTm9bXbLUj~FvbRpXNdg8wxDoSya8_M41@nFku6LU=GXZDSZ@;)KC+~36> zsm4f176ks|Gu|FuDTA!yZm8~ru*(ELT!GQhLZQ_Al|`^ZQe*brthdzSs62r@eN5n8 zxm$49GG%7m5brc@AI@>&s`h?$e3!=bK0Z@GhmJ~-5kv0{`pVOxkg{j^Jtk!ZZvIlM z755jnDWUiTJ#8tWsq}4@26`==YRf9Yi1f99LUxX96<3-p;m;R@Rb9WH5$^uNITFa6 z3Y;|FeyB<@TSCD?TU#XO_#ut2il{{*(jope1ku68KY9W2>Z8aSa?h@0SmPLR3x$B^ z_mngpZbD6vhS0=hX{^mzQfq&IoZLN?w@DqN;EsB>J>`!Q%H6h3$#z*ud77~9?)b^G zuwFJc&etYA0x~v29nmwil&0@;_Ypdx;|l*2oO4b5wDxIZ+7FAImHJ(eY|8F9JFF$$DJs-D>kl*UV*kZ5Aq}_0pfG_?%LL{=eptXFS&Y%yCwfcAszD^H==sZoyQk*mep0;OP`fUA+1kq`@y%N#Ad#n zg;s)hhBcGMVWBP@ApRIo!0dWThZ9)+b%L1~C15Ji!O+@yb@u*92mwpxbBNt4r0`8= zK#$%Hy;X5DUSn%NJTmdS)H3^5<>=d*X05H?3`Z1b895$0MLVm)t|gyxb*?>P?xcSD zCJ{CKkQQkH%sZvwPqxYbg;UvLf0aAe zD$Q#^EkyOtWjFS_z~J)u;^>{;fHR7pALY$UlApsAB+n;JH=tw0j8-kTn^46v72892 zjhie);6Yxcb%-M}rMS-aCXk`43zAxyKVV|X0D%rK!}jAgW!U*#h&k;3h%+ytCM%_w zR3VxMX^YyQm6D?I?_S95`yxA_<%OLUx=ySMVY7s8>ujX>R9*8-N|k@SA(clkK1Zzj zu}cVlRZTozUH6;Xezz@-B5})8qzjriRj)12EGLR{`$&1v&3**ye-I+NW>uEy(vhDK z*m1=P&)pJ9j#{DZOo-9e)6;>b>`I9n8f!58Dg6A%ZWXx#LrKpT*muP?ddfGdG&;z; zs3@#)ZzuUdA|<}3y9d!2Kf%%i;LeS2qrY`F>SqZ9N#v)o$VC#i4GKcJ}ltz62PaDPv$ybi9a{i6}S>|#E4P0cNYv8gffS)hgIXvO%l z&zK+*z1+~xI*_qMqwJvq5>xc1XNTLve#bn60lrP1GvX#n%?PVtXA-prQDNghIH$~N z?os_A^*}8S8UC{VT!ds@{tYsyeRdNnYe(kTDmo>AJo)v?)>hFlEZn=w8w7IGED{pD z)zs$G8?X%C8WE4qq3m)+t6Z`A&X0gp=ecfDB(bz`JpGx)$i8C6ig|*Dn{hJF-`PQ9 zR6)CY$*HXau%b(=>2g--+>77u8tIxf6+fMFGwyZ4bDO2NR-Ix4IKxaHwG?!bEh9=h zd3B4)aW|Cr)*3|iN_sEaj+{C5rGg2HCTg)|X=$2JQp@7`tpoIXg@Xd)%{8%jJpWBD+x zV?bA2N6(L@4bJ|I|2d#DR@| zpFUq{0x_LaR-;)uQd`#R9qL|Lw`%#LHtZY7sm?IudK6R?ROF>{e_ND>QC*}?aUgh8 zMme01S3S;gTckTD)B0M|%O&9v%?Gu8LO&XzB(}5n$YTr`^8f`?aq85yhWwGH{Fa2e zt#W$R9+yghb)_n$_8Y6j6?${fF*OIRV4RSHM+F3blDK=Ri_hNSlI|#NZAIH{Ug2hxvP1M*jWM>8 z6?9eIIg}iN-c^ay7BVIbFaXni@g*dIy7h2UW#5~juKT;0;n+!3wVU|6e}wCiHJjL) zSq!f5FvA@zNj3?6Vo5)1eX`NvffU+O1b&ux?zBo!I*arMkZx%aXd9ZyH~35kj&ch! zbl>UAULzMj`sKi)+2z=wr5*-P_9v-|(EbP#--Tjne?CyKaa4&Ae_(y~qH-F>^V2(d z=Mh$pwToLr1V)Q%*~gHfXm$?V$qC-?4&IrzZ(Og3Pp_=T0UdYf?R-30vX@Lf1(2?v zUu5TjsX9tFlRggGY2$ZP1}518wR{46jU%3X ze%270FQr>v8@`4%rO+dWnx+?y?`Ri_ECJByFtQpH={TFp4g)XG$?6MuOZcL*XN&^B!8?FPapvmg%$5?PKk|1D~`$ zfrnb@)LVA@TPIQGKe6SS=hrBXE&Zh^WY8QEi{Y>(6^@cvtIpBD=4#~RkD4ysE^b5M z;v$@eD#hIrm?FES4;zC%rG#vO;|P54_&00|v?8l?WjfocD1EC!idqBACN&I}7QdB{ zv(hD*p&Y+%tE)d_Fte7QtJ4&7m!=Qj)_*TIB%XdU$-*D?nW=!|9eOE_x%Cyi5gGt- zyQ%zS{ubM=@0Y;O0;`!S>MZ-lc;r@1OJVJTJpI^#8jkji7=Pguw?Lp$mM0F*@{#me zxIyhpuG;dj(+HJCdBnwcqiN4*Q`6l&HUF-8~&9>tumMrPL=&G(`g!Km$)-}PxZRhi6m_8IZrq;M(b;>LX(EQGR zQbMfYwZn+D=E0;iN)e)1bkwL!C4q?sjq-Q9I?M?w#fF3NQ)7Nlkr;lhDDzv;o(&!C zW!B}RbT{^lhy6bJVSnU5>mvHT_wphfOxcFz)$c=Cy0qycoX&$-kZC(~xH5Zf9*gCV z@czO*e)zNHPXqUbjT=)`KUQ2W#KDsibjGGacbgA3AuRV}SL==nVf{tr*dr@X6YL_P zWa+d@Qxaup$VZ^iPohubUiE7xcp<#21%FoZvQuT`v1vw=eM3XMU#qP0{&R<2;PUre4oM>9y*5L(K6g`9g(9iHgVB}U~)oj z7%8b#P)EZ7n^ux1BYe|0z9Al_5GJsLUN<_~4dGUZ%CR+O9c>Ri^f~@`V81fino-@& zuI;z_s@pl~67W13PeCj|UDDu4ISwU|sC;a3f9`(pK@TS7HoY6lpy^HGr8~tBELImL zdA3+mwf$`x|GY6Cc}MJ5+xL$qHPuY6x5ek(>cuHfl7;d*qG{|{Exki(|C5oyUT;VW zIR5O}y`=hWbC#pGe$Mjs*065JBwm`??uUnGJakDz4@!S%wM>T<{f*xlglW9gek^QjREHG zE^#`s>~AY@#Y{N|<`uK_xck4b?gVbQ2}X4?t%oi#V;@(8w=ykym5pg7Fkbq%Rb@xv zI0va>ZqYM!9x7RsAUMOhHWY#5Fhff#c8G-QC^8sfyJZ;!^#Yx@XxBnYN>+ehtXXbh^}M zi7Rmm$jcnBfsm76PtgTQ8Pwk3s5!|bPeJ@X^rGUD^kFztfu>vU{dB( z+fzL?H7LuSzj(jlLq5{9ik3deFY-?P2uI$=zhs&PR89Ed-LyhYx=Q_IWZ8;uMl$#o z#qZM1qjF^qi@Z~N3N78}LYqaw+?(zxDi>-B*00 zOMGP?HKJoe!oD$(CathH5^LnTZFq#|&3^{Z+9!x$4m%qkpvJ!f#FOK7=S`V<(DyCw zJ9Rb}^#nKNWG+TJ;Z2c**)pl*&Zxr;_x_FF?zV9yb zX)NpA&;j-EGR0Tw*-uSne|TlS`LM1b$muCeSlUUgI&;;~S*c0c6qHlTE%XS#8y8JE zMuoyv#h1_`^SJ_6TVnlh8Uze0{kO&q8}jfkS_&&I;lRpDAKYm^hNggung$p5u5Zl$ zZwiD2?IW$jH}tu0tYiJTVdBfroQJKU4iTJx;mlvK=k9}bjuPnRsfQ){s6RJwFZ$_4 z+EWKDtYj=1H%E?RTC0@HGpV_M=Xi}=SbU7R%HvPJHyabzcuBqP55s$ugg3RjlP>@W zIsrxOD=*&04%JR^M$#iWZ?$fv-4w@661Gp;>2srCmBf|VNiLJ_Ncq<{7{8S^xVve7 zBkYs*W67feo$({}QkW>VObqd|nU8F8UeCveQ0|Ji5B#~Pxfzp+e8j}>inuODe}3HE zVth2v^y2`kna{NmNqqt` zj3FGU#l~bbu-?~6{e@n%0X!Zzv7UzT9xAI|4S9y3&^`Lz1k3M+FqTkHp*+@E4SYK| z>M8pR2i?KRz=Q5pdUhy>g^9BAD+l9Ky@xXB^s6|0KyXG5Om2uMiWZpnH+%skuwZ6S|+&G*4qHbQ>~D zRQbQvPTJeYrjL8Mj*~=6f4-;8%}^uceBBNe;^%+nm?Ybk8~O|P9<>paD=Y&x;DSe@ zYR0OpZ^o}^J+P=(LjN4K{uj<{uvs)7(GghG{6v4NoZ-XMlt0|+n>e^7VjK7mj+LoN zg})eb;hAu>k+PU5w)JKHayNGa(t(`n=nYQGyEC!LyWKYB(oCTI9M*O`lH##N{4fE{HX&K;-&1@k2KCYZE;(t6 zkj2V=qMqePad8~D_m@cfrs%<8;Yk_ozRe-sdnuxJ*P$mXhP#`DEV<&9k1V<1P3h15 zg5IItc_lKYIfFuQ7$LZ>JIT{RE8nD?vLSsfleZZRwsbA}@K&S{&Q(+ZG3c}$LXT+X z3XvwbG^yMleF6HJd@m~CSrT;)T(l^TDtgq9xaNevZ2&eK&xrblBI8__ zXNxFUE|!6)|FPQYV$|UJutf8Xh-DFd%N!u@s5gw)A0P5@WU@5`c`t;`Hpe5m@UN8L+T!DXYmYSLJW*o5g}z{j0TAxEy5{O zoZOV$gD5Ej&11>m5DJuz1)@0nspNDLh&7-3-{1DXch5r{QfwB+J;$veUMStYlzE}7 z{6#tL4)x6+56(b(znOeW3F{_gtUdd|@w6!t?e`C5coAd|vX?5C<%?(HTq+$I!7)-) zM(1=ya0{l?vm%cfl3A40Ma8aSM7;{&C+gb(82v#40=ZC01x}>Y4EP(-?qBgAnN++} zy1IT-Uns#05<(y)PDKR9wDrR|Lh1;Fgh0`(^BWR~-AbA>MH${xz{ zk;z_HC!e}Lg3EhFf-V#`3OD@=VPyvK367j{rnF5X;(HB&S+fUqNMQcY;6!)3xAVub z3{-rIS3k2Y=~5@lsT)oJ-Rb1=ZoHw@GVSSmd}nNe5nM?(iY$`GHU#NQ%#Y-wVG-I$ z%0D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